feat: use TagMenu to cover all tag-based formatting

Additionally, ColorMenu has to be removed, and use Formatter directly rather than caching some token informations to the menu.
This commit is contained in:
kotaindah55 2025-03-17 10:41:52 +02:00
parent cf701cad4d
commit f54b8b831b
2 changed files with 185 additions and 171 deletions

View file

@ -1,171 +0,0 @@
import { EditorView } from "@codemirror/view";
import { Menu } from "obsidian";
import { ColorConfig, IndexCache, PlainRange, Token } from "src/types";
import { appFacet, settingsFacet } from "src/editor-mode/facets";
import { getActiveCanvasNodeCoords } from "src/editor-mode/utils";
export class ColorMenu extends Menu {
openRange: PlainRange;
tagRange: PlainRange;
closeRange: PlainRange;
itemIndexCache: IndexCache;
moveCursorAfterTag: boolean;
view: EditorView;
private constructor(view: EditorView, openRange: PlainRange, tagRange: PlainRange, closeRange: PlainRange, moveCursorAfterTag: boolean, itemIndexCache: IndexCache) {
super();
this.view = view;
this.openRange = openRange;
this.tagRange = tagRange;
this.closeRange = closeRange;
this.moveCursorAfterTag = moveCursorAfterTag;
this.dom.addClass("highlight-colors-modal");
this.itemIndexCache = itemIndexCache;
}
get openLen() {
return this.openRange.to - this.openRange.from;
}
get closeLen() {
return this.closeRange.to - this.closeRange.from;
}
get tagLen() {
return this.tagRange.to - this.tagRange.from;
}
addColorConfigs(configs: ColorConfig[]) {
configs.forEach(({ name, tag, showInMenu }) => {
if (!showInMenu) { return }
this.setItem(name, "palette", "menu-item-" + tag, () => { this.changeColor(tag) })
});
}
addAccent() {
this.setItem("Accent", "palette", "menu-item-accent", () => { this.changeColor("accent") });
}
addDefault() {
this.setItem("Default", "palette", "menu-item-default", () => { this.toDefault() });
}
addRemove() {
this.setItem("Remove", "eraser", "menu-item-remove-highlight", () => { this.removeColor() });
}
setItem(title: string, icon: string, cls: string, callback: (evt: MouseEvent | KeyboardEvent) => unknown) {
this.addItem(item => {
item.setTitle(title);
item.setIcon(icon);
item.dom.addClass(cls);
item.onClick(evt => {
let index = this.items.findIndex(i => i == item);
this.itemIndexCache.number = index;
callback(evt);
});
});
}
showMenu(offset?: number) {
this.checkItemIndexCache();
this.view.requestMeasure({
read: (view) => {
let app = view.state.facet(appFacet.reader),
canvasNodeCoords = getActiveCanvasNodeCoords(app),
charCoords = view.coordsForChar((offset ?? this.tagRange.from));
if (offset !== undefined && !charCoords) {
charCoords = view.coordsForChar(offset - 1);
}
return { charCoords, canvasNodeCoords };
},
write: (measure) => {
let { charCoords, canvasNodeCoords } = measure;
if (charCoords) {
let menuCoords = { x: charCoords.left, y: charCoords.bottom };
if (canvasNodeCoords) {
menuCoords.x += canvasNodeCoords.x;
menuCoords.y += canvasNodeCoords.y;
}
this.showAtPosition(menuCoords);
this.select(this.itemIndexCache.number);
}
}
});
}
adjustPos(differ: number) {
this.tagRange.to += differ;
this.closeRange.from += differ;
this.closeRange.to += differ;
}
changeColor(color: string) {
let oldTagRange = { from: this.tagRange.from, to: this.tagRange.to };
let differ = color.length + 2 - (this.tagRange.to - this.tagRange.from);
this.adjustPos(differ);
this.view.dispatch({
changes: {
from: oldTagRange.from,
to: oldTagRange.to,
insert: `{${color}}`
}
}, {
selection: this.moveCursorAfterTag ? {
anchor: this.tagRange.to,
head: this.tagRange.to
} : undefined,
sequential: true
});
}
removeColor() {
this.view.dispatch({
changes: [{
from: this.openRange.from,
to: this.tagRange.to,
insert: ""
}, {
from: this.closeRange.from,
to: this.closeRange.to,
insert: ""
}]
});
}
toDefault() {
this.view.dispatch({
changes: {
from: this.tagRange.from,
to: this.tagRange.to,
insert: ""
}
});
let tagLen = this.tagLen;
this.adjustPos(-tagLen);
}
onunload() {
(this.view as unknown as null) = null;
super.onunload();
}
checkItemIndexCache() {
if (this.itemIndexCache.number >= this.items.length) {
this.itemIndexCache.number = this.items.length - 1;
}
}
static create(
view: EditorView,
openRange: PlainRange,
tagRange: PlainRange,
closeRange: PlainRange,
moveCursorAfterTag: boolean,
itemIndexCache: IndexCache = { number: 0 },
option: { default?: boolean, accent?: boolean, remove?: boolean } = { default: true, accent: true, remove: true }
) {
let colorMenu = new ColorMenu(view, openRange, tagRange, closeRange, moveCursorAfterTag, itemIndexCache),
colorConfigs = view.state.facet(settingsFacet).colorConfigs;
colorMenu.addColorConfigs(colorConfigs);
if (option?.accent) { colorMenu.addAccent() }
if (option?.default) { colorMenu.addDefault() }
if (option?.remove) { colorMenu.addRemove() }
return colorMenu;
}
static fromToken(view: EditorView, hlToken: Token, moveCursorAfterTag: boolean, itemIndexCache: IndexCache = { number: 0 }, option: { default?: boolean, accent?: boolean, remove?: boolean } = { default: true, accent: true, remove: true }) {
let openRange = { from: hlToken.from, to: hlToken.from + hlToken.openLen },
tagRange = { from: openRange.to, to: openRange.to + hlToken.tagLen },
closeRange = { from: hlToken.to - hlToken.closeLen, to: hlToken.to },
colorConfigs = view.state.facet(settingsFacet).colorConfigs,
colorMenu = new ColorMenu(view, openRange, tagRange, closeRange, moveCursorAfterTag, itemIndexCache);
colorMenu.addColorConfigs(colorConfigs);
if (option?.accent) { colorMenu.addAccent() }
if (option?.default) { colorMenu.addDefault() }
if (option?.remove) { colorMenu.addRemove() }
return colorMenu;
}
}

View file

@ -0,0 +1,185 @@
import { Direction, EditorView } from "@codemirror/view";
import { IconName, Menu } from "obsidian";
import { IndexCache, ITagMenu, PluginSettings, TagConfig } from "src/types";
import { appFacet, settingsFacet } from "src/editor-mode/facets";
import { getActiveCanvasNodeCoords } from "src/editor-mode/editor-utils";
import { editorPlugin } from "src/editor-mode/extensions";
import { Format } from "src/enums";
import { Formatter } from "src/editor-mode/formatting";
import { supportTag } from "src/format-configs/utils";
export class TagMenu extends Menu {
itemIndexCache: IndexCache = { number: 0 };
view: EditorView;
type: Format;
formatter: Formatter;
settings: PluginSettings;
CLS_PREFIX = "ems-menu-item";
ICON: string;
constructor(view: EditorView, type: Format) {
super();
this.constructMenu(view, type);
}
constructMenu(view: EditorView, type: Format) {
this.view = view;
this.type = type;
this.formatter = view.plugin(editorPlugin)!.formatter;
this.settings = view.state.facet(settingsFacet);
this.bindIndexCache();
this.setClassPrefix();
this.setIcon();
this.addConfigs(this.getConfigs());
this.addDefaultItem();
this.addRemoveItem();
this.dom.addClass("ems-menu", "ems-tag-menu");
if (type == Format.HIGHLIGHT) {
this.dom.addClass("ems-color-menu");
}
}
setClassPrefix() {
if (this.type == Format.HIGHLIGHT) {
this.CLS_PREFIX += " ems-highlight-";
} else if (this.type == Format.CUSTOM_SPAN) {
this.CLS_PREFIX += " ems-span-";
} else if (this.type == Format.FENCED_DIV) {
this.CLS_PREFIX += " ems-div-";
}
}
setIcon() {
if (this.type == Format.HIGHLIGHT) {
this.ICON = "palette";
} else {
this.ICON = "shapes";
}
}
bindIndexCache() {
let { indexCache } = this.view.plugin(editorPlugin)!;
switch (this.type) {
case Format.HIGHLIGHT:
this.itemIndexCache = indexCache.colorMenuItem;
break;
case Format.CUSTOM_SPAN:
this.itemIndexCache = indexCache.spanTagMenuItem;
break;
case Format.FENCED_DIV:
this.itemIndexCache = indexCache.divTagMenuItem;
break;
default:
this.itemIndexCache = { number: 0 };
}
}
getConfigs() {
let configs: TagConfig[] = [];
if (this.type == Format.HIGHLIGHT) {
configs = this.settings.colorConfigs;
} else if (this.type == Format.CUSTOM_SPAN) {
configs = this.settings.predefinedSpanTag;
} else if (this.type == Format.FENCED_DIV) {
configs = this.settings.predefinedDivTag;
}
return configs;
}
addConfigs(configs: TagConfig[]) {
configs.forEach(({ name, tag, showInMenu }) => {
if (!showInMenu) { return }
this.addConfigItem(name, this.ICON, this.CLS_PREFIX + tag, () => { this.format(tag) });
});
if (this.type == Format.HIGHLIGHT && this.settings.showAccentColor) {
this.addConfigItem("Accent", this.ICON, this.CLS_PREFIX + "accent", () => { this.format("accent") });
}
}
addConfigItem(name: string, icon: IconName, cls: string, callback: (evt: MouseEvent | KeyboardEvent) => unknown) {
this.addItem(item => {
item.setTitle(name);
item.setIcon(icon);
item.dom.addClass(...cls.split(" "));
item.onClick(evt => {
let index = this.items.findIndex(i => i == item);
this.itemIndexCache.number = index;
callback(evt);
});
});
}
addDefaultItem() {
if (
this.type == Format.HIGHLIGHT && this.settings.showDefaultColor ||
this.type == Format.CUSTOM_SPAN && this.settings.showDefaultSpanTag
) {
this.addConfigItem("Default", this.ICON, this.CLS_PREFIX + "default", () => { this.format("") });
}
}
addRemoveItem() {
if (
this.type == Format.HIGHLIGHT && this.settings.showRemoveColor ||
this.type == Format.CUSTOM_SPAN && this.settings.showRemoveSpanTag
) {
this.addConfigItem("Remove", "eraser", "ems-menu-item ems-remove", () => { this.removeFormatting() });
}
}
format(tag: string) {
this.formatter.startFormat(this.type, tag);
}
removeFormatting() {
this.formatter.startFormat(this.type, undefined, true);
}
showMenu() {
this.checkItemIndexCache();
this.view.requestMeasure({
read: (view) => {
let app = view.state.facet(appFacet.reader),
canvasNodeCoords = getActiveCanvasNodeCoords(app),
charOffset = view.state.selection.ranges.at(-1)!.to,
charCoords = view.coordsForChar(charOffset);
if (!charCoords) {
let contentDOMCoords = view.contentDOM.getBoundingClientRect(),
firstBlock = view.lineBlockAt(charOffset),
isRTL = view.textDirection == Direction.RTL;
charCoords = {
top: contentDOMCoords.top,
bottom: contentDOMCoords.top + firstBlock.height,
left: isRTL ? contentDOMCoords.right : contentDOMCoords.left,
right: isRTL ? contentDOMCoords.left : contentDOMCoords.right
};
}
return { charCoords, canvasNodeCoords };
},
write: (measure) => {
let { charCoords, canvasNodeCoords } = measure;
if (charCoords) {
let menuCoords = { x: charCoords.left, y: charCoords.bottom };
if (canvasNodeCoords) {
menuCoords.x += canvasNodeCoords.x;
menuCoords.y += canvasNodeCoords.y;
}
this.showAtPosition(menuCoords);
this.select(this.itemIndexCache.number);
}
}
});
}
checkItemIndexCache() {
if (this.itemIndexCache.number >= this.items.length) {
this.itemIndexCache.number = this.items.length - 1;
}
}
static bindMenu(other: Menu, ...args: ConstructorParameters<typeof TagMenu>) {
let methodNames = ["addConfigItem", "addConfigs", "bindIndexCache", "checkItemIndexCache", "constructMenu", "format", "removeFormatting", "showMenu", "setClassPrefix", "setIcon", "addDefaultItem", "addRemoveItem", "getConfigs"] as const;
for (let methodName of methodNames) {
Object.defineProperty(other, methodName, {
value: this.prototype[methodName].bind(other)
});
}
Object.defineProperty(other, "CLS_PREFIX", {
value: "ems-menu-item",
configurable: true,
writable: true
});
(other as ITagMenu).constructMenu(...args);
return other as ITagMenu;
}
static create(...args: ConstructorParameters<typeof TagMenu>) {
let [view, type] = args;
if (!supportTag(type)) { throw new TypeError("This format type doesn't support custom tag.") }
return new this(view, type);
}
}