mirror of
https://github.com/k0src/Obsidian-Auto-Close-Tags-Plugin.git
synced 2026-07-22 13:00:31 +00:00
OOP Refactor
This commit is contained in:
parent
b6f60b15f3
commit
4f2ecf8c94
7 changed files with 645 additions and 334 deletions
358
main.ts
358
main.ts
|
|
@ -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
201
package-lock.json
generated
File diff suppressed because it is too large
Load diff
73
src/settings-tab.ts
Normal file
73
src/settings-tab.ts
Normal 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
31
src/settings.ts
Normal 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
102
src/tag-detector.ts
Normal 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
201
src/tag-processor.ts
Normal 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
13
src/types.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in a new issue