From d00a090099014e125203d47ee76d39da3a54aee4 Mon Sep 17 00:00:00 2001 From: dragonish Date: Mon, 31 Mar 2025 13:34:57 +0800 Subject: [PATCH] feat(outline): add support for outline plugin Resolve: #2 --- common/data.ts | 37 ++++++ common/dom.ts | 58 +++++++++ components/outline.ts | 40 ++++++ main.ts | 255 +++++++++++++++++++++++++++++++++++++-- styles.css | 57 +++++++++ test/common/data.spec.ts | 34 ++++++ tsconfig.json | 2 +- types.d.ts | 3 +- 8 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 components/outline.ts create mode 100644 test/common/data.spec.ts diff --git a/common/data.ts b/common/data.ts index 9a2bdf7..b3f858a 100644 --- a/common/data.ts +++ b/common/data.ts @@ -61,6 +61,7 @@ export type HeadingPluginSettings = { enabledInReading: boolean; enabledInPreview: boolean; enabledInSource: boolean; + enabledInOutline: boolean; } & Record; export type HeadingPluginData = Omit< @@ -74,6 +75,8 @@ export const previewHeadingDecoratorClassName = "preview-custom-heading-decorator"; export const sourceHeadingDecoratorClassName = "source-custom-heading-decorator"; +export const outlineHeadingDecoratorClassName = + "outline-custom-heading-decorator"; export const beforeDecoratorClassName = "before-heading-decorator"; export const afterDecoratorClassName = "after-heading-decorator"; export const headingsSelector = @@ -181,3 +184,37 @@ export function getUnorderedLevelHeadings(value: string): HeadingTuple { export function getOrderedCustomIdents(value: string) { return value.split(/\s+/g).filter((v) => v); } + +/** + * Diff level between two numbers. + * + * @param current current level. + * @param last last level. + * @returns boolean. if current level is less than or equal to last level, return true. otherwise, return false. + */ +export function diffLevel(current: number, last: number): boolean { + const diff = current - last; + if (diff > 0) { + return false; + } + return true; +} + +/** + * Compare heading text. + * + * @param l left heading text. + * @param r right heading text. + * @returns boolean. if left heading text is equal to right heading text, return true. otherwise, return false. + */ +export function compareHeadingText(l: string, r: string): boolean { + if (l === r) { + return true; + } else if ( + l.replaceAll(/[`=_~*\s]/g, "") === r.replaceAll(/[`=_~*\s]/g, "") + ) { + return true; + } + + return false; +} diff --git a/common/dom.ts b/common/dom.ts index 84266a5..225722b 100644 --- a/common/dom.ts +++ b/common/dom.ts @@ -2,6 +2,7 @@ import { readingHeadingDecoratorClassName, beforeDecoratorClassName, afterDecoratorClassName, + outlineHeadingDecoratorClassName, } from "./data"; /** @@ -69,3 +70,60 @@ export function decorateHTMLElement( ); } } + +/** + * Get the tree item level of a given element. + * + * @param element The element to get the tree item level for. + * @returns The tree item level. + */ +export function getTreeItemLevel(element: Element): number { + let level = 0; + let current = element.closest(".tree-item"); + while (current) { + level++; + current = current.parentElement?.closest(".tree-item") || null; + } + return level; +} + +/** + * Get the text of a given tree item. + * @param element The HTML element to get the text for. + * @returns The text of the tree item. + */ +export function getTreeItemText(element: HTMLElement): string { + const inner = element.querySelector( + ".tree-item-self .tree-item-inner" + ); + return inner ? inner.innerText : ""; +} + +/** + * Decorate an outline HTML element with a given content, opacity and position. + * + * @param element The HTML element to decorate. + * @param content The content to decorate with. + * @param opacity The opacity of the decorator. + * @param position The position of the decorator. + */ +export function decorateOutlineElement( + element: HTMLElement, + content: string, + opacity: OpacityOptions, + position: PostionOptions +): void { + if (content) { + const inner = element.querySelector( + ".tree-item-self .tree-item-inner" + ); + if (inner) { + inner.dataset.headingDecorator = content; + inner.dataset.decoratorOpacity = `${opacity}%`; + inner.classList.add( + outlineHeadingDecoratorClassName, + getPositionClassName(position) + ); + } + } +} diff --git a/components/outline.ts b/components/outline.ts new file mode 100644 index 0000000..469fc90 --- /dev/null +++ b/components/outline.ts @@ -0,0 +1,40 @@ +import { Component } from "obsidian"; + +export class OutlineChildComponent extends Component { + private containerEl: HTMLElement | null; + private callback: () => void; + private observer: MutationObserver | null = null; + private config: MutationObserverInit = { + childList: true, + subtree: true, + }; + + constructor(containerEl: HTMLElement, callback: () => void) { + super(); + this.containerEl = containerEl; + this.callback = callback; + } + + onload(): void { + this.callback(); + + this.observer = new MutationObserver(() => { + this.observer?.disconnect(); + this.callback(); + if (this.containerEl) { + this.observer?.observe(this.containerEl, this.config); + } + }); + + if (this.containerEl) { + this.observer.observe(this.containerEl, this.config); + } + } + + onunload(): void { + this.observer?.takeRecords(); + this.observer?.disconnect(); + this.observer = null; + this.containerEl = null; + } +} diff --git a/main.ts b/main.ts index 8a43b4e..39ce9f0 100644 --- a/main.ts +++ b/main.ts @@ -6,6 +6,7 @@ import { PluginSettingTab, Setting, Editor, + WorkspaceLeaf, debounce, } from "obsidian"; import { EditorView, ViewPlugin } from "@codemirror/view"; @@ -20,21 +21,34 @@ import { defaultHeadingDecoratorSettings, getUnorderedLevelHeadings, getOrderedCustomIdents, + diffLevel, + compareHeadingText, } from "./common/data"; import { Counter, Querier } from "./common/counter"; import { Heading } from "./common/heading"; -import { decorateHTMLElement, queryHeadingLevelByElement } from "./common/dom"; +import { + decorateHTMLElement, + decorateOutlineElement, + queryHeadingLevelByElement, + getTreeItemLevel, + getTreeItemText, +} from "./common/dom"; import { HeadingViewPlugin, headingDecorationsField, editorModeField, updateEditorMode, } from "./components/view"; +import { OutlineChildComponent } from "./components/outline"; interface ObsidianEditor extends Editor { cm: EditorView; } +interface ObsidianWorkspaceLeaf extends WorkspaceLeaf { + id: string; +} + const DEFAULT_SETTINGS: HeadingPluginSettings = { enabledInReading: true, readingSettings: defaultHeadingDecoratorSettings(), @@ -42,11 +56,16 @@ const DEFAULT_SETTINGS: HeadingPluginSettings = { previewSettings: defaultHeadingDecoratorSettings(), enabledInSource: false, sourceSettings: defaultHeadingDecoratorSettings(), + enabledInOutline: false, + outlineSettings: defaultHeadingDecoratorSettings(), }; export default class HeadingPlugin extends Plugin { settings: HeadingPluginSettings; + private outlineIdList: string[] = []; + private outlineComponents: OutlineChildComponent[] = []; + async onload() { await this.loadSettings(); @@ -218,28 +237,57 @@ export default class HeadingPlugin extends Plugin { // Listen for editor mode changes this.registerEvent( - this.app.workspace.on( - "active-leaf-change", - this.handleModeChange.bind(this) - ) + this.app.workspace.on("active-leaf-change", () => { + this.handleModeChange(); + this.loadOutlineComponents(); + }) ); this.registerEvent( - this.app.workspace.on("layout-change", this.handleModeChange.bind(this)) + this.app.workspace.on("layout-change", () => { + this.handleModeChange(); + this.loadOutlineComponents(); + }) ); + this.loadOutlineComponents(); + this.addSettingTab(new HeadingSettingTab(this.app, this)); } + onunload(): void { + this.unloadOutlineComponents(); + } + async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } - async saveSettings() { + async saveSettings(outlineState?: boolean) { await this.saveData(this.settings); + if (outlineState != undefined) { + if (outlineState) { + this.loadOutlineComponents(); + } else { + this.unloadOutlineComponents(); + } + } + this.debouncedSaveSettings(); } + private loadOutlineComponents() { + if (this.settings.enabledInOutline) { + this.handleOutline(); + } + } + + private unloadOutlineComponents() { + this.outlineComponents.forEach((child) => child.onunload()); + this.outlineComponents = []; + this.outlineIdList = []; + } + private craeteHeadingViewPlugin( getPluginData: () => Promise ) { @@ -269,6 +317,176 @@ export default class HeadingPlugin extends Plugin { } } + private handleOutline() { + const leaves = this.app.workspace.getLeavesOfType("outline"); + leaves.forEach((leaf: ObsidianWorkspaceLeaf) => { + if (!leaf.id || this.outlineIdList.includes(leaf.id)) { + return; + } + + const view = leaf.view; + const viewContent = view.containerEl.querySelector( + '[data-type="outline"] .view-content' + ); + if (!viewContent) { + return; + } + + this.outlineIdList.push(leaf.id); + const oc = new OutlineChildComponent(viewContent, () => { + const headingElements = + viewContent.querySelectorAll(".tree-item"); + if (headingElements.length === 0) { + return; + } + + const { + opacity, + position, + ordered, + orderedDelimiter, + orderedTrailingDelimiter, + orderedStyleType, + orderedSpecifiedString, + orderedCustomIdents, + orderedIgnoreSingle, + orderedBasedOnExisting, + orderedAllowZeroLevel, + unorderedLevelHeadings, + } = this.settings.outlineSettings; + + let fromFile = false; + const state = view.getState(); + if (typeof state.file === "string") { + const file = this.app.vault.getFileByPath(state.file); + if (file) { + const cacheHeadings = + this.app.metadataCache.getFileCache(file)?.headings || []; + + let ignoreTopLevel = 0; + if (ordered && (orderedIgnoreSingle || orderedBasedOnExisting)) { + const queier = new Querier(orderedAllowZeroLevel); + for (const cacheHeading of cacheHeadings) { + queier.handler(cacheHeading.level); + ignoreTopLevel = queier.query(orderedIgnoreSingle); + if (ignoreTopLevel === 0) { + break; + } + } + } + + const counter = new Counter({ + ordered, + delimiter: orderedDelimiter, + trailingDelimiter: orderedTrailingDelimiter, + styleType: orderedStyleType, + customIdents: getOrderedCustomIdents(orderedCustomIdents), + specifiedString: orderedSpecifiedString, + ignoreTopLevel, + allowZeroLevel: orderedAllowZeroLevel, + levelHeadings: getUnorderedLevelHeadings(unorderedLevelHeadings), + }); + + let lastCacheLevel = 0; + let lastReadLevel = 0; + for ( + let i = 0, j = 0; + i < headingElements.length && j < cacheHeadings.length; + i++, j++ + ) { + const readLevel = getTreeItemLevel(headingElements[i]); + const readText = getTreeItemText(headingElements[i]); + let cacheLevel = cacheHeadings[j].level; + if (i > 0) { + const diff = diffLevel(readLevel, lastReadLevel); + while ( + j < cacheHeadings.length - 1 && + (diffLevel(cacheLevel, lastCacheLevel) !== diff || + !compareHeadingText(cacheHeadings[j].heading, readText)) + ) { + counter.handler(cacheLevel); + j++; + cacheLevel = cacheHeadings[j].level; + } + } + + const decoratorContent = counter.decorator(cacheLevel); + decorateOutlineElement( + headingElements[i], + decoratorContent, + opacity, + position + ); + + lastCacheLevel = cacheLevel; + lastReadLevel = readLevel; + } + + fromFile = true; + } + } + + if (!fromFile) { + if (ordered) { + let ignoreTopLevel = 0; + if (orderedIgnoreSingle || orderedBasedOnExisting) { + const queier = new Querier(); + for (let i = 0; i < headingElements.length; i++) { + const element = headingElements[i]; + const level = getTreeItemLevel(element); + queier.handler(level); + ignoreTopLevel = queier.query(orderedIgnoreSingle); + if (ignoreTopLevel === 0) { + break; + } + } + } + + const counter = new Counter({ + ordered: true, + delimiter: orderedDelimiter, + trailingDelimiter: orderedTrailingDelimiter, + styleType: orderedStyleType, + customIdents: getOrderedCustomIdents(orderedCustomIdents), + specifiedString: orderedSpecifiedString, + ignoreTopLevel, + allowZeroLevel: orderedAllowZeroLevel, + }); + + headingElements.forEach((headingElement) => { + const level = getTreeItemLevel(headingElement); + const decoratorContent = counter.decorator(level); + decorateOutlineElement( + headingElement, + decoratorContent, + opacity, + position + ); + }); + } else { + const counter = new Counter({ + ordered: false, + levelHeadings: getUnorderedLevelHeadings(unorderedLevelHeadings), + }); + + headingElements.forEach((headingElement) => { + const level = getTreeItemLevel(headingElement); + const decoratorContent = counter.decorator(level); + decorateOutlineElement( + headingElement, + decoratorContent, + opacity, + position + ); + }); + } + } + }); + this.outlineComponents.push(oc); + view.addChild(oc); + }); + } + private debouncedSaveSettings = debounce( this.rerenderPreviewMarkdown.bind(this), 1000, @@ -378,6 +596,29 @@ class HeadingSettingTab extends PluginSettingTab { this.manageHeadingDecoratorSettings("sourceSettings"); }); }); + + //* enabledInOutline + new Setting(containerEl) + .setName("Enabled in outline plugin") + .setDesc("Decorate the heading under the outline plugin.") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.enabledInOutline) + .onChange(async (value) => { + this.plugin.settings.enabledInOutline = value; + await this.plugin.saveSettings( + this.plugin.settings.enabledInOutline + ); + }) + ); + + new Setting(containerEl) + .setName("Manage outline plugin heading decorator") + .addButton((button) => { + button.setButtonText("Manage").onClick(() => { + this.manageHeadingDecoratorSettings("outlineSettings"); + }); + }); } private isOpacityValue(value: number): value is OpacityOptions { diff --git a/styles.css b/styles.css index 69ef02d..ce6998b 100644 --- a/styles.css +++ b/styles.css @@ -384,3 +384,60 @@ opacity: 100%; } /* source end */ + +/* outline start */ +.outline-custom-heading-decorator.before-heading-decorator::before, +.outline-custom-heading-decorator.after-heading-decorator::after { + content: attr(data-heading-decorator); + opacity: 20%; +} + +.outline-custom-heading-decorator.before-heading-decorator::before { + margin-inline-end: var(--size-4-1); +} + +.outline-custom-heading-decorator.after-heading-decorator::after { + margin-inline-start: var(--size-4-1); +} + +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="10%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="10%"]::after { + opacity: 10%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="20%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="20%"]::after { + opacity: 20%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="30%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="30%"]::after { + opacity: 30%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="40%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="40%"]::after { + opacity: 40%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="50%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="50%"]::after { + opacity: 50%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="60%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="60%"]::after { + opacity: 60%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="70%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="70%"]::after { + opacity: 70%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="80%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="80%"]::after { + opacity: 80%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="90%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="90%"]::after { + opacity: 90%; +} +.outline-custom-heading-decorator.before-heading-decorator[data-decorator-opacity="100%"]::before, +.outline-custom-heading-decorator.after-heading-decorator[data-decorator-opacity="100%"]::after { + opacity: 100%; +} +/* outline end */ diff --git a/test/common/data.spec.ts b/test/common/data.spec.ts new file mode 100644 index 0000000..04ad6cc --- /dev/null +++ b/test/common/data.spec.ts @@ -0,0 +1,34 @@ +import "mocha"; +import { expect } from "chai"; +import { diffLevel, compareHeadingText } from "../../common/data"; + +describe("common/data", function () { + it("diffLevel", function () { + expect(diffLevel(1, 1)).to.be.true; + expect(diffLevel(2, 1)).to.be.false; + expect(diffLevel(1, 2)).to.be.true; + expect(diffLevel(3, 1)).be.false; + expect(diffLevel(1, 3)).to.be.true; + expect(diffLevel(4, 1)).be.false; + expect(diffLevel(1, 4)).to.be.true; + expect(diffLevel(5, 2)).be.false; + expect(diffLevel(2, 5)).to.be.true; + expect(diffLevel(6, 1)).be.false; + expect(diffLevel(1, 6)).to.be.true; + }); + + it("compareHeadingText", function () { + expect(compareHeadingText("", "")).to.be.true; + expect(compareHeadingText("h1", "h1")).to.be.true; + expect(compareHeadingText("h1", "h2")).to.be.false; + expect(compareHeadingText("h1", "`h1`")).to.be.true; + expect(compareHeadingText("h1 h2", "`h1` h2")).to.be.true; + expect(compareHeadingText("h1 `", "h1 `` ` ``")).to.be.true; + expect(compareHeadingText("h1", "*h1*")).to.be.true; + expect(compareHeadingText("h1", "**h1**")).to.be.true; + expect(compareHeadingText("h1", "_h1_")).to.be.true; + expect(compareHeadingText("h1", "__h1__")).to.be.true; + expect(compareHeadingText("h1", "==h1==")).to.be.true; + expect(compareHeadingText("h1", "~~h1~~")).to.be.true; + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a4108b9..455e742 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, - "lib": ["DOM", "ES5", "ES6", "ES7"] + "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"] }, "include": ["**/*.ts"] } diff --git a/types.d.ts b/types.d.ts index fb5e2b4..9b8ab85 100644 --- a/types.d.ts +++ b/types.d.ts @@ -16,4 +16,5 @@ type PostionOptions = "before" | "after"; type PluginDecoratorSettingsType = | "readingSettings" | "previewSettings" - | "sourceSettings"; + | "sourceSettings" + | "outlineSettings";