feat(outline): add support for outline plugin

Resolve: #2
This commit is contained in:
dragonish 2025-03-31 13:34:57 +08:00
parent eaa8b0dce6
commit d00a090099
No known key found for this signature in database
GPG key ID: 6F42FA9E807A5177
8 changed files with 477 additions and 9 deletions

View file

@ -61,6 +61,7 @@ export type HeadingPluginSettings = {
enabledInReading: boolean;
enabledInPreview: boolean;
enabledInSource: boolean;
enabledInOutline: boolean;
} & Record<PluginDecoratorSettingsType, HeadingDecoratorSettings>;
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;
}

View file

@ -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<HTMLElement>(
".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<HTMLElement>(
".tree-item-self .tree-item-inner"
);
if (inner) {
inner.dataset.headingDecorator = content;
inner.dataset.decoratorOpacity = `${opacity}%`;
inner.classList.add(
outlineHeadingDecoratorClassName,
getPositionClassName(position)
);
}
}
}

40
components/outline.ts Normal file
View file

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

255
main.ts
View file

@ -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<HeadingPluginData>
) {
@ -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<HTMLElement>(
'[data-type="outline"] .view-content'
);
if (!viewContent) {
return;
}
this.outlineIdList.push(leaf.id);
const oc = new OutlineChildComponent(viewContent, () => {
const headingElements =
viewContent.querySelectorAll<HTMLElement>(".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 {

View file

@ -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 */

34
test/common/data.spec.ts Normal file
View file

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

View file

@ -11,7 +11,7 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
"lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"]
},
"include": ["**/*.ts"]
}

3
types.d.ts vendored
View file

@ -16,4 +16,5 @@ type PostionOptions = "before" | "after";
type PluginDecoratorSettingsType =
| "readingSettings"
| "previewSettings"
| "sourceSettings";
| "sourceSettings"
| "outlineSettings";