insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
198 lines (167 loc) • 6.87 kB
text/typescript
import {
TFile,
App,
htmlToMarkdown,
Editor,
EditorPosition
} from "obsidian";
import {
markdownLinkRegex,
tagLinkRegex,
wikiLinkNoAliasRegex,
wikiLinkWithAliasRegex
} from "./constants";
import {
EditorData,
HandledLink,
HeadingLevel,
IndentLevel,
ListItemContext
} from "./types";
import InstaTocPlugin from "./main";
// Handle the codeblock list item and return the indent level and navigation link
export function handleCodeblockListItem(
app: App,
plugin: InstaTocPlugin,
file: TFile,
listItemMatch: RegExpMatchArray,
filePath: string
): ListItemContext {
let [, indent, bullet, content]: RegExpMatchArray = listItemMatch;
let { contentText, alias } = handleLinks(plugin, content);
const navLink: string = app.fileManager.generateMarkdownLink(
file, filePath, `#${contentText}`, alias
);
return { indent, bullet, navLink };
}
// Handle links in the content and alias of a list item
export function handleLinks(plugin: InstaTocPlugin, content: string): HandledLink {
let [contentText, alias]: string[] = [content, content];
// Process Obsidian wiki links with alias
contentText = contentText.replace(wikiLinkWithAliasRegex, (match, refPath, refAlias) => {
// Text including [[wikilink|wikitext]] -> Text including wikilink wikitext
return `${refPath} ${refAlias}`;
});
alias = alias.replace(wikiLinkWithAliasRegex, (match, refPath, refAlias) => {
// [[wikilink|wikitext]] -> wikitext
return refAlias;
});
// Process Obsidian wiki links without alias
contentText = contentText.replace(wikiLinkNoAliasRegex, (match, refPath) => {
// Text including [[wikilink]] -> Text including wikilink
// OR
// Text including [[path/to/wikilink]] -> Text including wikilink
return refPath.split('/').pop() ?? refPath;
});
alias = alias.replace(wikiLinkNoAliasRegex, (match, refPath) => {
// [[wikilink]] -> wikilink
// OR
// [[path/to/wikilink]] -> wikilink
return refPath.split('/').pop() ?? refPath;
});
// Process markdown links
contentText = contentText.replace(markdownLinkRegex, (match, refAlias) => {
// Text including [Link](https://www.link.com) -> Text including [Link](https://www.link.com)
return match;
});
alias = alias.replace(markdownLinkRegex, (match, refAlias) => {
// [Link](https://www.link.com) -> Link
return refAlias;
});
// Clean up tags
contentText = contentText.replace(tagLinkRegex, (match, symbol, tag) => { // Remove any tags
// Text including #a-tag -> Text including a-tag
return tag;
});
// Process HTML and exluded characters
alias = cleanAlias(alias, plugin);
return { contentText, alias };
}
// Strip the alias of specified excluded characters and convert HTML to markdown
export function cleanAlias(aliasText: string, plugin?: InstaTocPlugin, exclChars?: string[]): string {
const excludedChars = (plugin ? plugin.settings.excludedChars : exclChars) ?? [];
let alias: string = htmlToMarkdown(aliasText); // Convert any possible HTML to markdown
// Replace all specified excluded characters
for (const char of excludedChars) alias = alias.replaceAll(char, '');
return alias;
}
// Configure indentation for the insta-toc code block HTML element, post-render
export function configureRenderedIndent(
el: HTMLElement,
headingLevels: number[],
indentSize: IndentLevel
): void {
const listItems: NodeListOf<HTMLLIElement> = el.querySelectorAll('li');
listItems.forEach((listItem: HTMLLIElement, index: number) => {
const headingLevel: number = headingLevels[index];
// Only adjust indentation for headings beyond H1 (headingLevel > 1)
if (headingLevel > 1) {
listItem.style.marginInlineStart = `${indentSize * 10}px`;
}
const subList: HTMLUListElement | HTMLOListElement | null = listItem.querySelector('ul, ol');
if (subList) {
// List item has children
const toggleButton: HTMLButtonElement = document.createElement('button');
toggleButton.textContent = '▾'; // Down arrow
toggleButton.classList.add('fold-toggle');
// Event listener to toggle visibility
toggleButton.addEventListener('click', () => {
if (subList.style.display === 'none') {
subList.style.display = '';
toggleButton.textContent = '▾';
} else {
subList.style.display = 'none';
toggleButton.textContent = '▸';
}
});
listItem.prepend(toggleButton);
}
});
}
// Get the editor and cursor position
export function getEditorData(app: App): EditorData {
const editor: Editor | undefined = app.workspace.activeEditor?.editor
const cursorPos: EditorPosition | undefined = editor?.getCursor();
return { editor, cursorPos }
}
// Escape special characters in a string for use in a regular expression
export function escapeRegExp(string: string): string {
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
}
// Check if a string is a regex pattern
export function isRegexPattern(string: string): boolean {
// Checks if the string starts and ends with '/'
return /^\/.*\/$/.test(string);
}
// Check if a string is a valid heading level
export function isHeadingLevel(value: any): value is HeadingLevel {
return [1, 2, 3, 4, 5, 6].includes(value);
}
// Check if a value is an object that can be merged
function isMergeableObject(value: any): boolean {
return value && typeof value === 'object' && !Array.isArray(value);
}
// Deep merge two objects
export function deepMerge<T>(target: Partial<T>, source: Partial<T>, dedupeArrays = true): T {
if (isMergeableObject(target) && isMergeableObject(source)) {
for (const key of Object.keys(source) as Array<keyof T>) {
const targetValue = target[key];
const sourceValue = source[key];
if (isMergeableObject(sourceValue)) {
if (!targetValue) {
(target as any)[key] = {};
}
deepMerge(target[key] as any, sourceValue as any);
} else if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
if (dedupeArrays) {
(target as any)[key] = [...new Set(targetValue.concat(sourceValue))];
} else {
(target as any)[key] = sourceValue;
}
} else {
(target as any)[key] = sourceValue;
}
}
}
return target as T;
}