First push

This commit is contained in:
kotaindah55 2024-09-25 12:01:47 +03:00
parent f3f8b64333
commit eefa267fbb
59 changed files with 3049 additions and 0 deletions

48
esbuild.config.mjs Normal file
View file

@ -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();
}

1070
main.js Normal file

File diff suppressed because it is too large Load diff

37
main.ts Normal file
View file

@ -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");
}
}

9
manifest.json Normal file
View file

@ -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
}

25
package.json Normal file
View file

@ -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"
}
}

146
readme.md Normal file
View file

@ -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 <u>underlined</u>, <sup>superscripted</sup>, or <sub>subscripted</sub> 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...

60
src/declarations/index.d.ts vendored Normal file
View file

@ -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<MarkdownView>[];
}
interface EventRef<T = unknown> {
ctx?: T;
}
interface MarkdownView {
path: string;
}
interface MarkdownPostProcessorContext {
containerEl: HTMLElement;
el: HTMLElement;
}
}
declare module "@codemirror/view" {
interface EditorView {
docView: DocView;
}
interface DocView {
children: DocViewBlock<any>[];
}
interface DocViewBlock<T extends BlockWidget> {
widget?: T;
}
class BlockWidget extends WidgetType {
toDOM(view: EditorView): HTMLElement;
containerEl: HTMLElement;
text: string;
}
class TableWidget extends BlockWidget {
cellChildMap: Map<TableCell, unknown>;
}
}

View file

@ -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<Decoration>[] = [];
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<Decoration>[], 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
});

View file

@ -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<Decoration>[] = [];
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<Decoration>[], 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
});

View file

@ -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"})
}),
]

View file

@ -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"})})]
]);

View file

@ -0,0 +1,5 @@
import { Decoration } from "@codemirror/view";
export const hiddenSpoiler = Decoration.mark({
class: "cmx-spoiler cmx-spoiler-hidden"
});

View file

@ -0,0 +1,6 @@
import { Decoration } from "@codemirror/view";
export const highlightDecorator = {
class: "cmx-highlight cmx-highlight",
markerDeco: Decoration.mark({class: "cmx-highlight-marker"})
}

View file

@ -0,0 +1,4 @@
export * from "./alignerDecorators";
export * from "./highlightDecorator";
export * from "./formattingDecorators";
export * from "./hiddenSpoiler";

View file

@ -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<Decoration>[] = [];
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<Decoration>[], 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
});

View file

@ -0,0 +1,3 @@
export * from "./extendedFormattingPlugin";
export * from "./alignerPlugin";
export * from "./customHighlightPlugin";

View file

@ -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 = /(?:(?<!\\)|(?<=(?<!\\)(?:\\{2})+))==+(?=(?:\{([\w\d\-]+)\})|[^=\s])/g;
let hlCollection: HighlightProp[] = [];
if (previous) {
hlCollection = previous.hlCollection;
let {from} = previous,
lastIndex = from - 2;
hlCollection.length && hlCollection.findLast((hlProp, index, collection) => {
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<typeof getHighlight>, 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;
}
})

View file

@ -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 = /(?<!^)\|/g,
delimPosCollection: DelimPos[] = [];
if (previous) {
delimPosCollection = previous.posCollection;
let {from, delimLength} = previous,
lastIndex = /* type == "spoiler" ? doc.lineAt(from).from : */ from - delimLength;
delimPosCollection.length && delimPosCollection.findLast((delimPos, index, collection) => {
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<DelimType, ReturnType<typeof getDelimiterFromEditor>>, treeLength: number}> = StateField.define({
create(state) {
let delimField = new Map<DelimType, ReturnType<typeof getDelimiterFromEditor>>();
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;
}
});

View file

@ -0,0 +1,2 @@
export * from "./extendedFormattingField";
export * from "./customHighlightField";

View file

@ -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;
}
}

View file

@ -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");
}
}

View file

@ -0,0 +1,2 @@
export * from "./ColorButton";
export * from "./HiddenWidget";

6
src/enums/DelimType.ts Normal file
View file

@ -0,0 +1,6 @@
export enum DelimType {
U = "u",
Sup = "sup",
Sub = "sub",
Spoiler = "spoiler"
}

1
src/enums/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./DelimType";

View file

@ -0,0 +1,4 @@
export interface BlockquoteChildSection {
text: string;
level: number;
}

1
src/interfaces/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./BlockquoteChildSection";

View file

@ -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<HTMLElement>(this.targetedElements).forEach((el) => {
this.format(el);
});
}
}
}

View file

@ -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<ExtendedElement>("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);
}
}

View file

@ -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<HTMLElement>("td, th")[i], rawText, true);
});
} else if (firstChild?.tagName == "BLOCKQUOTE") {
getBlockquoteSections(rawTextData, false).forEach((section, i) => {
this.format(container.querySelectorAll<HTMLElement>(this.targetedElements)[i], section.text);
});
} else if (firstChild?.classList.contains("callout")) {
getBlockquoteSections(rawTextData, true).forEach((section, i) => {
this.format(container.querySelectorAll<HTMLElement>(this.targetedElements)[i], section.text);
});
} else {
container.querySelectorAll<HTMLElement>(this.targetedElements).forEach(contentEl => {
this.format(contentEl, rawTextData);
});
}
container.querySelectorAll<HTMLElement>("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<TableWidget> | 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<BlockWidget> | 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<HTMLElement>(this.targetedElements)[i], section.text);
});
}
}
}
}

View file

@ -0,0 +1,3 @@
export * from "./AlignerPostProcessor";
export * from "./CustomHighlightPostProcessor";
export * from "./ExtendedFormattingPostProcessor";

View file

@ -0,0 +1,32 @@
import { DelimType } from "../enums";
export const delimRegExps = new Map([
[DelimType.U, {
openingDelim: /(?:(?<!\\)|(?<=(?<!\\)(?:\\{2})+))\+\+(?![\+\s]|$)/gd,
closingDelim: /(?:(?<![\s\\]|^)|(?<=(?<!\\)(?:\\{2})+))\+\+/gd,
length: 2
}],
[DelimType.Sup, {
openingDelim: /(?:(?<!\\)|(?<=(?<!\\)(?:\\{2})+))\^(?![\^\s]|$)/gd,
closingDelim: /(?:(?<![\s\\]|^)|(?<=(?<!\\)(?:\\{2})+))\^/gd,
length: 1
}],
[DelimType.Sub, {
openingDelim: /(?:(?<!\\)|(?<=(?<!\\)(?:\\{2})+))~(?![~\s]|$)/gd,
closingDelim: /(?:(?<![\s\\]|^)|(?<=(?<!\\)(?:\\{2})+))~/gd,
length: 1
}],
[DelimType.Spoiler, {
openingDelim: /(?:(?<!\\)|(?<=(?<!\\)(?:\\{2})+))\|\|(?![|\s]|$)/gd,
closingDelim: /(?:(?<![\s\\]|^)|(?<=(?<!\\)(?:\\{2})+))\|\|/gd,
length: 2
}]
]);
/*
single: /((?<!\\)(?:\\{2})*\\)?(\^()?)(?!$|\s)(?=(\^)?)/gd
double: /((?<!\\)(?:\\{2})*\\)?(\^(\\)?\^)(?!$|\s)(?=(\^)?)/gd
single: /(?:((?<!\\)(?:\\{2})*(?<!\s)\\)|(?<![\\\s])|((?<!\\)(?:\\{2})+))(\^()?)/gd
double: /(?:((?<!\\)(?:\\{2})*(?<!\s)\\)|(?<![\\\s])|((?<!\\)(?:\\{2})+))(\^(\\)?\^)/gd
*/

2
src/regExps/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./delimRegExps";
export * from "./postProcessorDelimRegExps";

View file

@ -0,0 +1,40 @@
import { DelimType } from "../enums";
export const postProcessorDelimRegExps = new Map([
[DelimType.U, {
openingDelim: /\+\+(?![\s]|$|(?:<br>\n?))/gd,
closingDelim: /(?<![\s]|(?:<br>\n?))\+\+/gd,
raw: {
openingDelim: /((?<!\\)(?:\\{2})*\\)?(\+(\\)?\+)(?!$|\s)(?=(\+)?)/gd,
closingDelim: /(?:((?<!\\)(?:\\{2})*(?<!\s)\\)|(?<![\\\s])|((?<!\\)(?:\\{2})+))(\+(\\)?\+)/gd
},
length: 2
}],
[DelimType.Sup, {
openingDelim: /\^(?![\s]|$|(?:<br>\n?))/gd,
closingDelim: /(?<![\s]|(?:<br>\n?))\^/gd,
raw: {
openingDelim: /((?<!\\)(?:\\{2})*\\)?(\^()?)(?!$|\s)(?=(\^)?)/gd,
closingDelim: /(?:((?<!\\)(?:\\{2})*(?<!\s)\\)|(?<![\\\s])|((?<!\\)(?:\\{2})+))(\^()?)/gd
},
length: 1
}],
[DelimType.Sub, {
openingDelim: /~(?![\s]|$|(?:<br>\n?))/gd,
closingDelim: /(?<![\s]|(?:<br>\n?))~/gd,
raw: {
openingDelim: /((?<!\\)(?:\\{2})*\\)?(~()?)(?!$|\s)(?=(~)?)/gd,
closingDelim: /(?:((?<!\\)(?:\\{2})*(?<!\s)\\)|(?<![\\\s])|((?<!\\)(?:\\{2})+))(~()?)/gd
},
length: 1
}],
[DelimType.Spoiler, {
openingDelim: /\|\|(?![\s]|$|(?:<br>\n?))/gd,
closingDelim: /(?<![\s]|(?:<br>\n?))\|\|/gd,
raw: {
openingDelim: /((?<!\\)(?:\\{2})*\\)?(\|(\\)?\|)(?!$|\s)(?=(\|)?)/gd,
closingDelim: /(?:((?<!\\)(?:\\{2})*(?<!\s)\\)|(?<![\\\s])|((?<!\\)(?:\\{2})+))(\|(\\)?\|)/gd
},
length: 2
}]
])

1
src/types/DelimPos.ts Normal file
View file

@ -0,0 +1 @@
export type DelimPos = [number, number, number, number];

View file

@ -0,0 +1 @@
export type ExtendedElement = Element & HTMLElement;

View file

@ -0,0 +1 @@
export type HighlightProp = [number, number, number, number, string | undefined];

1
src/types/Pos.ts Normal file
View file

@ -0,0 +1 @@
export type Pos = [number, number];

4
src/types/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from "./ExtendedElement";
export * from "./Pos";
export * from "./HighlightProp";
export * from "./DelimPos";

View file

@ -0,0 +1,17 @@
import { EditorSelection } from "@codemirror/state";
/**
* These code snippets are taken from
* https://github.com/Mara-Li/obsidian-regex-mark/blob/master/src/cmPlugin.ts
* with some modifications.
*/
export function checkSelectionOverlap(selection: EditorSelection, from: number, to: number): boolean {
if (!selection) {return false}
for (const range of selection.ranges) {
if (range.to >= from && range.from <= to) {
return true;
}
return false;
}
return false;
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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");
}

2
src/utils/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./getTextAtLine";
export * from "./replaceStringAtPos";

View file

@ -0,0 +1,27 @@
/**
* @deprecated
*/
export function excludeElements<T, P extends any[]>(contentEl: HTMLElement, selector: string, callback: (...args: P) => T, ...args: Parameters<typeof callback>): T {
let excludedContents: string[] = [];
let excludedEl = contentEl.querySelectorAll<HTMLElement>(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);
}
}

View file

@ -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;
}

View file

@ -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<typeof postProcessorDelimRegExps.get>) {
let newLineChar = /(<br>\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;
}

View file

@ -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;
}

View file

@ -0,0 +1,13 @@
import { Pos } from "../../types";
export function getRestrictedRanges(text: string) {
let query = /(\\)|`(?:[^`])+`|\[\[\[?(?!\[)(?:.|\s)+?(?<!\])\]\]|\$\$(?:.|\s)*?\$\$|\$(?!\s|\$).+?(?<!\s)\$/gd;
let restrictedRanges: Pos[] = [];
for (let match = query.exec(text); match; match = query.exec(text)) {
match[1] ? query.lastIndex += 1 : restrictedRanges.push(match.indices![0]);
}
return restrictedRanges;
}

View file

@ -0,0 +1,7 @@
export * from "./splitCells";
export * from "./excludeElements";
export * from "./getDelimiterFromPreview";
export * from "./getEscapedDelims";
export * from "./getRestrictedRange";
export * from "./iterDelimReplacement";
export * from "./getBlockquoteSections";

View file

@ -0,0 +1,41 @@
import { type DelimType } from "../../enums";
import { type postProcessorDelimRegExps } from "../../regExps";
import { replaceStringByPos } from "../../utils";
import { getDelimiterFromPreview } from "./getDelimiterFromPreview";
export function iterDelimReplacement(contentEl: HTMLElement, rawText: string, delimQuery: ReturnType<typeof postProcessorDelimRegExps.get>, tagName: DelimType, excludedSelector: string, isTableCell?: boolean) {
isTableCell && tagName == "spoiler" && (rawText = "");
let excludedContents: string[] = [];
let excludedEl = contentEl.querySelectorAll<HTMLElement>(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, `</${tagName}>`, 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<HTMLElement>(excludedSelector)[index].innerHTML = content;
});
}

View file

@ -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(/(?:(?<!\\)|(?<=(?<!\\)(?:\\{2})+))\|/).forEach(cell => {
cells.push(cell);
});
});
return cells;
}

View file

@ -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);
}

153
styles.css Normal file
View file

@ -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;
}
}

17
tsconfig.json Normal file
View file

@ -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/*"
]
}