OOP Refactor

This commit is contained in:
k0 2025-08-02 22:07:50 -05:00
parent b6f60b15f3
commit 4f2ecf8c94
7 changed files with 645 additions and 334 deletions

358
main.ts
View file

@ -1,26 +1,19 @@
import { App, Editor, Plugin, PluginSettingTab, Setting } from "obsidian";
interface AutoCloseTagsSettings {
excludedTags: string;
cursorPosition: "between" | "after";
ignoreInCodeBlocks: boolean;
ignoreInlineCode: boolean;
}
const DEFAULT_SETTINGS: AutoCloseTagsSettings = {
excludedTags: "",
cursorPosition: "between",
ignoreInCodeBlocks: false,
ignoreInlineCode: true,
};
import { Editor, Plugin } from "obsidian";
import { AutoCloseTagsSettings } from "./src/types";
import { SettingsManager } from "./src/settings";
import { TagProcessor } from "./src/tag-processor";
import { AutoCloseTagsSettingTab } from "./src/settings-tab";
export default class AutoCloseTags extends Plugin {
private allowAutoClose: boolean = false;
settings: AutoCloseTagsSettings;
private lastProcessedPosition: { line: number; ch: number } | null = null;
private settingsManager: SettingsManager;
private tagProcessor: TagProcessor;
async onload() {
await this.loadSettings();
this.settingsManager = new SettingsManager(this);
await this.settingsManager.loadSettings();
this.tagProcessor = new TagProcessor(this.settingsManager.settings);
this.registerDomEvent(document, "paste", () => {
this.allowAutoClose = true;
@ -54,12 +47,12 @@ export default class AutoCloseTags extends Plugin {
handleTyping(editor: Editor) {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const lastPos = this.tagProcessor.getLastProcessedPosition();
if (
this.lastProcessedPosition &&
this.lastProcessedPosition.line === cursor.line &&
this.lastProcessedPosition.ch === cursor.ch
lastPos &&
lastPos.line === cursor.line &&
lastPos.ch === cursor.ch
) {
return;
}
@ -67,305 +60,32 @@ export default class AutoCloseTags extends Plugin {
if (!this.allowAutoClose) return;
this.allowAutoClose = false;
if (cursor.ch === 0 || line[cursor.ch - 1] !== ">") {
return;
}
const beforeCursor = line.slice(0, cursor.ch);
const tagMatch = beforeCursor.match(/<([a-zA-Z][\w\-]*)\s*([^>]*)>$/);
if (
!tagMatch ||
beforeCursor.endsWith("/>") ||
beforeCursor.endsWith("</")
) {
return;
}
const tag = tagMatch[1];
const fullTag = tagMatch[0];
if (this.isSelfClosing(fullTag)) return;
const excluded = this.settings.excludedTags
.split(",")
.map((t) => t.trim().toLowerCase())
.filter((t) => t.length > 0);
if (excluded.includes(tag.toLowerCase())) return;
if (
(this.settings.ignoreInCodeBlocks &&
this.isInCodeBlock(editor, cursor.line)) ||
(this.settings.ignoreInlineCode &&
this.isInInlineCode(line, cursor.ch))
) {
return;
}
const closingTag = `</${tag}>`;
const insertPos = { line: cursor.line, ch: cursor.ch };
this.lastProcessedPosition = { line: cursor.line, ch: cursor.ch };
editor.replaceRange(closingTag, insertPos);
if (this.settings.cursorPosition === "between") {
editor.setCursor({ line: cursor.line, ch: cursor.ch });
} else {
editor.setCursor({
line: cursor.line,
ch: cursor.ch + closingTag.length,
});
}
setTimeout(() => {
this.lastProcessedPosition = null;
}, 100);
}
hasMatchingClosingTag(
editor: Editor,
tagName: string,
fromLine: number,
fromCh: number
): boolean {
const content = editor.getValue();
const lines = content.split("\n");
let openCount = 1;
for (let i = fromLine; i < lines.length; i++) {
const line = lines[i];
const searchFrom = i === fromLine ? fromCh : 0;
const searchLine = line.slice(searchFrom);
const openMatches = [
...searchLine.matchAll(
new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "gi")
),
];
openCount += openMatches.length;
const closeMatches = [
...searchLine.matchAll(new RegExp(`</${tagName}>`, "gi")),
];
openCount -= closeMatches.length;
if (openCount <= 0) {
return true;
}
}
return false;
}
insertClosingTag(editor: Editor) {
const content = editor.getValue();
const lines = content.split("\n");
const excluded = this.settings.excludedTags
.split(",")
.map((t) => t.trim().toLowerCase())
.filter((t) => t.length > 0);
const openTags: { tag: string; line: number }[] = [];
const closeTags: string[] = [];
let insideCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("```")) {
insideCodeBlock = !insideCodeBlock;
continue;
}
if (this.settings.ignoreInCodeBlocks && insideCodeBlock) continue;
const openMatches = [
...line.matchAll(/<([a-zA-Z][\w\-]*)\s*([^>]*?)>/g),
];
for (const match of openMatches) {
const raw = match[0];
const tag = match[1].toLowerCase();
const matchIndex = match.index ?? 0;
if (excluded.includes(tag)) continue;
if (this.isSelfClosing(raw)) continue;
if (
this.settings.ignoreInlineCode &&
this.isInInlineCode(line, matchIndex)
)
continue;
openTags.push({ tag, line: i });
}
const closeMatches = [...line.matchAll(/<\/([a-zA-Z][\w\-]*)>/g)];
for (const match of closeMatches) {
const matchIndex = match.index ?? 0;
if (
this.settings.ignoreInlineCode &&
this.isInInlineCode(line, matchIndex)
)
continue;
closeTags.push(match[1].toLowerCase());
}
}
for (const closeTag of closeTags) {
const openIndex = openTags.findLastIndex(
(openTag) => openTag.tag === closeTag
);
if (openIndex !== -1) {
openTags.splice(openIndex, 1);
}
}
if (openTags.length === 0) {
return;
}
const tagToClose = openTags[openTags.length - 1].tag;
const closingTag = `</${tagToClose}>`;
const cursor = editor.getCursor();
editor.replaceRange(closingTag, cursor);
if (this.settings.cursorPosition === "after") {
editor.setCursor({
line: cursor.line,
ch: cursor.ch + closingTag.length,
});
} else {
editor.setCursor(cursor);
}
}
isSelfClosing(tag: string): boolean {
if (/\/\s*>$/.test(tag)) return true;
const voidElements = [
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr",
];
const tagName = tag.match(/<([a-zA-Z][\w\-]*)/)?.[1]?.toLowerCase();
return tagName ? voidElements.includes(tagName) : false;
}
isInCodeBlock(editor: Editor, line: number): boolean {
let inCodeBlock = false;
for (let i = 0; i <= line; i++) {
const currentLine = editor.getLine(i);
if (currentLine && currentLine.trim().startsWith("```")) {
inCodeBlock = !inCodeBlock;
}
}
return inCodeBlock;
}
isInInlineCode(line: string, index: number): boolean {
const before = line.slice(0, index);
const backticks = (before.match(/`/g) || []).length;
return backticks % 2 === 1;
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
this.tagProcessor.autoCloseTag(
editor,
this.settingsManager.getExcludedTags()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
}
/* ---------------------------- Settings Tab ---------------------------- */
class AutoCloseTagsSettingTab extends PluginSettingTab {
plugin: AutoCloseTags;
constructor(app: App, plugin: AutoCloseTags) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Excluded tags")
.setDesc(
"Comma-separated list of tags that should not be auto-closed (e.g., div, span, i)."
)
.addText((text) =>
text
.setPlaceholder("e.g., div, span, i")
.setValue(this.plugin.settings.excludedTags)
.onChange(async (value) => {
this.plugin.settings.excludedTags = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Cursor position after auto-close")
.setDesc("Where to place the cursor after auto-closing a tag.")
.addDropdown((dropdown) =>
dropdown
.addOption("between", "Between tags")
.addOption("after", "After closing tag")
.setValue(this.plugin.settings.cursorPosition)
.onChange(async (value: "between" | "after") => {
this.plugin.settings.cursorPosition = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Ignore fenced code blocks")
.setDesc(
"Avoid auto-closing tags inside Markdown code blocks (``` blocks)."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ignoreInCodeBlocks)
.onChange(async (value) => {
this.plugin.settings.ignoreInCodeBlocks = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Ignore inline code")
.setDesc(
"Avoid auto-closing tags inside inline code spans (e.g., `<div>`)."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ignoreInlineCode)
.onChange(async (value) => {
this.plugin.settings.ignoreInlineCode = value;
await this.plugin.saveSettings();
})
);
insertClosingTag(editor: Editor) {
this.tagProcessor.insertClosingTag(
editor,
this.settingsManager.getExcludedTags()
);
}
getSettings(): AutoCloseTagsSettings {
return this.settingsManager.settings;
}
updateSetting<K extends keyof AutoCloseTagsSettings>(
key: K,
value: AutoCloseTagsSettings[K]
): void {
this.settingsManager.settings[key] = value;
this.tagProcessor.updateSettings(this.settingsManager.settings);
}
async saveSettings(): Promise<void> {
await this.settingsManager.saveSettings();
}
}

201
package-lock.json generated

File diff suppressed because it is too large Load diff

73
src/settings-tab.ts Normal file
View file

@ -0,0 +1,73 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import AutoCloseTags from "../main";
export class AutoCloseTagsSettingTab extends PluginSettingTab {
plugin: AutoCloseTags;
constructor(app: App, plugin: AutoCloseTags) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Excluded tags")
.setDesc(
"Comma-separated list of tags that should not be auto-closed (e.g., div, span, i)."
)
.addText((text) =>
text
.setPlaceholder("e.g., div, span, i")
.setValue(this.plugin.getSettings().excludedTags)
.onChange(async (value) => {
this.plugin.updateSetting("excludedTags", value);
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Cursor position after auto-close")
.setDesc("Where to place the cursor after auto-closing a tag.")
.addDropdown((dropdown) =>
dropdown
.addOption("between", "Between tags")
.addOption("after", "After closing tag")
.setValue(this.plugin.getSettings().cursorPosition)
.onChange(async (value: "between" | "after") => {
this.plugin.updateSetting("cursorPosition", value);
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Ignore fenced code blocks")
.setDesc(
"Avoid auto-closing tags inside Markdown code blocks (``` blocks)."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.getSettings().ignoreInCodeBlocks)
.onChange(async (value) => {
this.plugin.updateSetting("ignoreInCodeBlocks", value);
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Ignore inline code")
.setDesc(
"Avoid auto-closing tags inside inline code spans (e.g., `<div>`)."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.getSettings().ignoreInlineCode)
.onChange(async (value) => {
this.plugin.updateSetting("ignoreInlineCode", value);
await this.plugin.saveSettings();
})
);
}
}

31
src/settings.ts Normal file
View file

@ -0,0 +1,31 @@
import { Plugin } from "obsidian";
import { AutoCloseTagsSettings, DEFAULT_SETTINGS } from "./types";
export class SettingsManager {
private plugin: Plugin;
public settings: AutoCloseTagsSettings;
constructor(plugin: Plugin) {
this.plugin = plugin;
this.settings = { ...DEFAULT_SETTINGS };
}
async loadSettings(): Promise<void> {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.plugin.loadData()
);
}
async saveSettings(): Promise<void> {
await this.plugin.saveData(this.settings);
}
getExcludedTags(): string[] {
return this.settings.excludedTags
.split(",")
.map((t) => t.trim().toLowerCase())
.filter((t) => t.length > 0);
}
}

102
src/tag-detector.ts Normal file
View file

@ -0,0 +1,102 @@
import { Editor } from "obsidian";
import { AutoCloseTagsSettings } from "./types";
export class TagDetector {
private settings: AutoCloseTagsSettings;
constructor(settings: AutoCloseTagsSettings) {
this.settings = settings;
}
updateSettings(settings: AutoCloseTagsSettings): void {
this.settings = settings;
}
isSelfClosing(tag: string): boolean {
if (/\/\s*>$/.test(tag)) return true;
const voidElements = [
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr",
];
const tagName = tag.match(/<([a-zA-Z][\w\-]*)/)?.[1]?.toLowerCase();
return tagName ? voidElements.includes(tagName) : false;
}
isInCodeBlock(editor: Editor, line: number): boolean {
let inCodeBlock = false;
for (let i = 0; i <= line; i++) {
const currentLine = editor.getLine(i);
if (currentLine && currentLine.trim().startsWith("```")) {
inCodeBlock = !inCodeBlock;
}
}
return inCodeBlock;
}
isInInlineCode(line: string, index: number): boolean {
const before = line.slice(0, index);
const backticks = (before.match(/`/g) || []).length;
return backticks % 2 === 1;
}
isTagExcluded(tag: string, excludedTags: string[]): boolean {
return excludedTags.includes(tag.toLowerCase());
}
shouldIgnorePosition(
editor: Editor,
line: string,
lineNumber: number,
position: number
): boolean {
if (
this.settings.ignoreInCodeBlocks &&
this.isInCodeBlock(editor, lineNumber)
) {
return true;
}
if (
this.settings.ignoreInlineCode &&
this.isInInlineCode(line, position)
) {
return true;
}
return false;
}
extractTagFromLine(
line: string,
cursorPosition: number
): { tag: string; fullTag: string } | null {
const beforeCursor = line.slice(0, cursorPosition);
const tagMatch = beforeCursor.match(/<([a-zA-Z][\w\-]*)\s*([^>]*)>$/);
if (
!tagMatch ||
beforeCursor.endsWith("/>") ||
beforeCursor.endsWith("</")
) {
return null;
}
return {
tag: tagMatch[1],
fullTag: tagMatch[0],
};
}
}

201
src/tag-processor.ts Normal file
View file

@ -0,0 +1,201 @@
import { Editor } from "obsidian";
import { AutoCloseTagsSettings } from "./types";
import { TagDetector } from "./tag-detector";
export class TagProcessor {
private settings: AutoCloseTagsSettings;
private tagDetector: TagDetector;
private lastProcessedPosition: { line: number; ch: number } | null = null;
constructor(settings: AutoCloseTagsSettings) {
this.settings = settings;
this.tagDetector = new TagDetector(settings);
}
updateSettings(settings: AutoCloseTagsSettings): void {
this.settings = settings;
this.tagDetector.updateSettings(settings);
}
setLastProcessedPosition(
position: { line: number; ch: number } | null
): void {
this.lastProcessedPosition = position;
}
getLastProcessedPosition(): { line: number; ch: number } | null {
return this.lastProcessedPosition;
}
autoCloseTag(editor: Editor, excludedTags: string[]): boolean {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (cursor.ch === 0 || line[cursor.ch - 1] !== ">") {
return false;
}
const tagInfo = this.tagDetector.extractTagFromLine(line, cursor.ch);
if (!tagInfo) {
return false;
}
const { tag, fullTag } = tagInfo;
if (this.tagDetector.isSelfClosing(fullTag)) {
return false;
}
if (this.tagDetector.isTagExcluded(tag, excludedTags)) {
return false;
}
if (
this.tagDetector.shouldIgnorePosition(
editor,
line,
cursor.line,
cursor.ch
)
) {
return false;
}
const closingTag = `</${tag}>`;
const insertPos = { line: cursor.line, ch: cursor.ch };
this.lastProcessedPosition = { line: cursor.line, ch: cursor.ch };
editor.replaceRange(closingTag, insertPos);
if (this.settings.cursorPosition === "between") {
editor.setCursor({ line: cursor.line, ch: cursor.ch });
} else {
editor.setCursor({
line: cursor.line,
ch: cursor.ch + closingTag.length,
});
}
setTimeout(() => {
this.lastProcessedPosition = null;
}, 100);
return true;
}
hasMatchingClosingTag(
editor: Editor,
tagName: string,
fromLine: number,
fromCh: number
): boolean {
const content = editor.getValue();
const lines = content.split("\n");
let openCount = 1;
for (let i = fromLine; i < lines.length; i++) {
const line = lines[i];
const searchFrom = i === fromLine ? fromCh : 0;
const searchLine = line.slice(searchFrom);
const openMatches = [
...searchLine.matchAll(
new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "gi")
),
];
openCount += openMatches.length;
const closeMatches = [
...searchLine.matchAll(new RegExp(`</${tagName}>`, "gi")),
];
openCount -= closeMatches.length;
if (openCount <= 0) {
return true;
}
}
return false;
}
insertClosingTag(editor: Editor, excludedTags: string[]): void {
const content = editor.getValue();
const lines = content.split("\n");
const openTags: { tag: string; line: number }[] = [];
const closeTags: string[] = [];
let insideCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("```")) {
insideCodeBlock = !insideCodeBlock;
continue;
}
if (this.settings.ignoreInCodeBlocks && insideCodeBlock) continue;
const openMatches = [
...line.matchAll(/<([a-zA-Z][\w\-]*)\s*([^>]*?)>/g),
];
for (const match of openMatches) {
const raw = match[0];
const tag = match[1].toLowerCase();
const matchIndex = match.index ?? 0;
if (excludedTags.includes(tag)) continue;
if (this.tagDetector.isSelfClosing(raw)) continue;
if (
this.settings.ignoreInlineCode &&
this.tagDetector.isInInlineCode(line, matchIndex)
)
continue;
openTags.push({ tag, line: i });
}
const closeMatches = [...line.matchAll(/<\/([a-zA-Z][\w\-]*)>/g)];
for (const match of closeMatches) {
const matchIndex = match.index ?? 0;
if (
this.settings.ignoreInlineCode &&
this.tagDetector.isInInlineCode(line, matchIndex)
)
continue;
closeTags.push(match[1].toLowerCase());
}
}
for (const closeTag of closeTags) {
const openIndex = openTags.findLastIndex(
(openTag) => openTag.tag === closeTag
);
if (openIndex !== -1) {
openTags.splice(openIndex, 1);
}
}
if (openTags.length === 0) {
return;
}
const tagToClose = openTags[openTags.length - 1].tag;
const closingTag = `</${tagToClose}>`;
const cursor = editor.getCursor();
editor.replaceRange(closingTag, cursor);
if (this.settings.cursorPosition === "after") {
editor.setCursor({
line: cursor.line,
ch: cursor.ch + closingTag.length,
});
} else {
editor.setCursor(cursor);
}
}
}

13
src/types.ts Normal file
View file

@ -0,0 +1,13 @@
export interface AutoCloseTagsSettings {
excludedTags: string;
cursorPosition: "between" | "after";
ignoreInCodeBlocks: boolean;
ignoreInlineCode: boolean;
}
export const DEFAULT_SETTINGS: AutoCloseTagsSettings = {
excludedTags: "",
cursorPosition: "between",
ignoreInCodeBlocks: false,
ignoreInlineCode: true,
};