mirror of
https://github.com/moranrs/table-master.git
synced 2026-07-22 06:53:11 +00:00
- src/editor/actions.ts: drop two unnecessary type assertions on navigator.clipboard
- src/i18n/en.ts: rewrite 9 strings into sentence case (drop button-name parentheticals like ' (Tab)' and 'Excel/web')
- src/i18n/zh.ts: drop now-unused plugin.name key
- src/main.ts: split async init out of onload into a private bootstrap()
so Plugin.onload's declared void return type is preserved
- src/main.ts, src/i18n/index.ts, src/editor/actions.ts: remove unused
'_' catch parameters (use bare 'catch')
- src/render/tableRenderer.ts: replace 'Function' with an explicit
MarkdownRenderFn signature for the renderer.render fallback
- src/settings.ts: drop the manual createEl('h2', ...) heading;
Obsidian renders the plugin name as the settings-tab heading
- src/ui/floatingToolbar.ts: replace inline element.style.display
writes with an 'is-hidden' CSS class toggle, drop redundant
inline 'position: fixed' (now CSS-only), and parse SVG icons via
DOMParser instead of innerHTML
- styles.css: add .tm-floating-toolbar.is-hidden rule
- add eslint.config.mjs + dev dependencies so contributors can
reproduce ObsidianReviewBot's checks locally
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { Editor, MarkdownView, Notice, Plugin } from "obsidian";
|
|
import { Prec } from "@codemirror/state";
|
|
import { keymap } from "@codemirror/view";
|
|
import { DEFAULT_SETTINGS, TableMasterSettings, TableMasterSettingTab } from "./settings";
|
|
import { setLanguage, t } from "./i18n";
|
|
import * as actions from "./editor/actions";
|
|
import { getActionContext } from "./editor/contextHelper";
|
|
import { locateTable } from "./editor/tableLocator";
|
|
import { parseTable } from "./table/parser";
|
|
import { navigateCell, navigateRowEnter } from "./editor/cellNavigator";
|
|
import { buildFloatingToolbarExt } from "./ui/floatingToolbar";
|
|
import { registerContextMenu } from "./ui/contextMenu";
|
|
import { buildTableMergePostProcessor } from "./render/postProcessor";
|
|
import { buildLivePreviewExt } from "./render/livePreview";
|
|
import { GridEditorModal } from "./ui/gridEditorModal";
|
|
import { NewTableModal } from "./ui/newTableModal";
|
|
import { emptyModel, TableModel } from "./table/model";
|
|
|
|
const CONFLICT_PLUGIN_IDS = [
|
|
"table-editor-obsidian", // Advanced Tables
|
|
"obsidian-markdown-table-editor",
|
|
"table-extended",
|
|
];
|
|
|
|
export default class TableMasterPlugin extends Plugin {
|
|
settings: TableMasterSettings = DEFAULT_SETTINGS;
|
|
|
|
onload(): void {
|
|
// Obsidian's `Plugin.onload` signature is declared as `void` even though
|
|
// it accepts a Promise at runtime; declaring this method `async` makes
|
|
// typescript-eslint's `no-misused-promises` flag the override mismatch.
|
|
// Keep the wrapper synchronous and run the actual init in a fire-and-
|
|
// forget helper. We purposely register every editor extension and
|
|
// command immediately (without waiting for `loadSettings`) so plugin
|
|
// lifecycle is consistent; settings have a sane default value (see
|
|
// `DEFAULT_SETTINGS`) until the on-disk copy finishes loading.
|
|
void this.bootstrap();
|
|
}
|
|
|
|
private async bootstrap(): Promise<void> {
|
|
await this.loadSettings();
|
|
setLanguage(this.settings.language);
|
|
|
|
// Reading-view post-processor for merged cells
|
|
this.registerMarkdownPostProcessor(
|
|
buildTableMergePostProcessor({ component: this }),
|
|
);
|
|
|
|
// Floating toolbar (CM6 ViewPlugin)
|
|
this.registerEditorExtension(
|
|
buildFloatingToolbarExt({
|
|
getApp: () => this.app,
|
|
getPlugin: () => this,
|
|
}),
|
|
);
|
|
|
|
// Live Preview merge renderer (CM6 ViewPlugin). The implementation only
|
|
// toggles rowspan/colspan attributes and hides placeholder cells, so it
|
|
// does not interfere with Obsidian's table widget the way a full DOM
|
|
// rebuild would.
|
|
this.registerEditorExtension(buildLivePreviewExt());
|
|
|
|
// Tab / Shift-Tab / Enter navigation inside tables
|
|
this.registerEditorExtension(
|
|
Prec.high(
|
|
keymap.of([
|
|
{
|
|
key: "Tab",
|
|
run: () => {
|
|
if (!this.settings.enableTabNavigation) return false;
|
|
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (!view) return false;
|
|
return navigateCell(view.editor, "next");
|
|
},
|
|
},
|
|
{
|
|
key: "Shift-Tab",
|
|
run: () => {
|
|
if (!this.settings.enableTabNavigation) return false;
|
|
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (!view) return false;
|
|
return navigateCell(view.editor, "prev");
|
|
},
|
|
},
|
|
{
|
|
key: "Enter",
|
|
run: () => {
|
|
if (!this.settings.enableTabNavigation) return false;
|
|
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (!view) return false;
|
|
const loc = locateTable(view.editor);
|
|
if (!loc) return false;
|
|
return navigateRowEnter(view.editor);
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
|
|
// Right-click menu
|
|
registerContextMenu(this);
|
|
|
|
// When the user switches between markdown leaves (split panes, hover
|
|
// editors, secondary tabs), Obsidian doesn't necessarily emit a CM6
|
|
// update on the leaving view, so its floating toolbar would otherwise
|
|
// remain visible alongside the new active leaf's toolbar. Re-broadcast
|
|
// the same event the settings tab uses; every ViewPlugin instance is
|
|
// already listening and will re-evaluate its visibility against the new
|
|
// active leaf.
|
|
this.registerEvent(
|
|
this.app.workspace.on("active-leaf-change", () => {
|
|
document.dispatchEvent(new CustomEvent("table-master:settings-changed"));
|
|
}),
|
|
);
|
|
|
|
// Commands
|
|
this.registerCommands();
|
|
|
|
// Settings tab
|
|
this.addSettingTab(new TableMasterSettingTab(this.app, this));
|
|
|
|
// Conflict warning
|
|
this.warnIfConflictingPlugins();
|
|
}
|
|
|
|
onunload(): void {
|
|
// Final safety sweep: Obsidian doesn't always reconfigure already-open
|
|
// CodeMirror views when a plugin unloads, so any floating toolbars that
|
|
// were attached to a markdown view's wrapper may otherwise leak. Remove
|
|
// every DOM node we tagged on construction.
|
|
document
|
|
.querySelectorAll<HTMLElement>('[data-tm-floating-toolbar="1"]')
|
|
.forEach((el) => el.remove());
|
|
}
|
|
|
|
async loadSettings(): Promise<void> {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings(): Promise<void> {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
private registerCommands(): void {
|
|
const editorCmd = (id: string, name: string, fn: (ctx: actions.ActionContext) => void) => {
|
|
this.addCommand({
|
|
id,
|
|
name,
|
|
editorCallback: (editor: Editor) => {
|
|
const ctx = { editor, format: this.settings.outputFormat };
|
|
fn(ctx);
|
|
},
|
|
});
|
|
};
|
|
|
|
editorCmd("insert-row-above", t("cmd.insertRowAbove"), actions.insertRowAbove);
|
|
editorCmd("insert-row-below", t("cmd.insertRowBelow"), actions.insertRowBelow);
|
|
editorCmd("insert-col-left", t("cmd.insertColLeft"), actions.insertColLeft);
|
|
editorCmd("insert-col-right", t("cmd.insertColRight"), actions.insertColRight);
|
|
editorCmd("delete-row", t("cmd.deleteRow"), actions.deleteRow);
|
|
editorCmd("delete-col", t("cmd.deleteCol"), actions.deleteCol);
|
|
editorCmd("move-row-up", t("cmd.moveRowUp"), actions.moveRowUp);
|
|
editorCmd("move-row-down", t("cmd.moveRowDown"), actions.moveRowDown);
|
|
editorCmd("move-col-left", t("cmd.moveColLeft"), actions.moveColLeft);
|
|
editorCmd("move-col-right", t("cmd.moveColRight"), actions.moveColRight);
|
|
editorCmd("align-left", t("cmd.alignLeft"), (c) => actions.alignColumn(c, "left"));
|
|
editorCmd("align-center", t("cmd.alignCenter"), (c) => actions.alignColumn(c, "center"));
|
|
editorCmd("align-right", t("cmd.alignRight"), (c) => actions.alignColumn(c, "right"));
|
|
editorCmd("align-none", t("cmd.alignNone"), (c) => actions.alignColumn(c, "none"));
|
|
editorCmd("merge-up", t("cmd.mergeUp"), actions.mergeUp);
|
|
editorCmd("merge-down", t("cmd.mergeDown"), actions.mergeDown);
|
|
editorCmd("merge-left", t("cmd.mergeLeft"), actions.mergeLeft);
|
|
editorCmd("split-cell", t("cmd.splitCell"), actions.splitCell);
|
|
editorCmd("format-table", t("cmd.formatTable"), actions.formatTable);
|
|
editorCmd("sort-asc", t("cmd.sortAsc"), (c) => actions.sort(c, "asc"));
|
|
editorCmd("sort-desc", t("cmd.sortDesc"), (c) => actions.sort(c, "desc"));
|
|
|
|
// Open grid editor (works on the table at the cursor)
|
|
this.addCommand({
|
|
id: "open-grid-editor",
|
|
name: t("cmd.openGridEditor"),
|
|
editorCallback: () => this.openGridEditor(),
|
|
});
|
|
|
|
// Toggle floating toolbar
|
|
this.addCommand({
|
|
id: "toggle-floating-toolbar",
|
|
name: t("cmd.toggleFloating"),
|
|
callback: async () => {
|
|
this.settings.showFloatingToolbar = !this.settings.showFloatingToolbar;
|
|
await this.saveSettings();
|
|
},
|
|
});
|
|
|
|
// Insert new table directly (no grid editor)
|
|
this.addCommand({
|
|
id: "new-table",
|
|
name: t("cmd.newTable"),
|
|
editorCallback: (editor: Editor) => {
|
|
new NewTableModal(this.app, (spec) => {
|
|
actions.insertNewTable(
|
|
{ editor, format: this.settings.outputFormat },
|
|
spec.rows,
|
|
spec.cols,
|
|
spec.hasHeader,
|
|
);
|
|
}).open();
|
|
},
|
|
});
|
|
|
|
// Design new table in the grid editor and insert at cursor on confirm.
|
|
this.addCommand({
|
|
id: "design-new-table",
|
|
name: t("cmd.designNewTable"),
|
|
editorCallback: (editor: Editor) => {
|
|
new NewTableModal(
|
|
this.app,
|
|
(spec) => {
|
|
const model = emptyModel(spec.rows, spec.cols);
|
|
if (!spec.hasHeader) model.headerRows = 0;
|
|
new GridEditorModal(this.app, model, (newModel) => {
|
|
actions.insertModelAtCursor(
|
|
{ editor, format: this.settings.outputFormat },
|
|
newModel,
|
|
);
|
|
}).open();
|
|
},
|
|
t("newTable.design"),
|
|
).open();
|
|
},
|
|
});
|
|
|
|
// Import table from clipboard (Excel / web)
|
|
this.addCommand({
|
|
id: "import-table-from-clipboard",
|
|
name: t("cmd.importTable"),
|
|
editorCallback: () => void this.importTableFromClipboard(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read a table from the system clipboard (HTML or TSV) and either replace
|
|
* the current table or insert a new one at the cursor. Reports outcome via
|
|
* an Obsidian notice.
|
|
*/
|
|
async importTableFromClipboard(): Promise<void> {
|
|
const ctx = getActionContext(this.app, this);
|
|
if (!ctx) {
|
|
new Notice(t("notice.importFailed"));
|
|
return;
|
|
}
|
|
let result: Awaited<ReturnType<typeof actions.importTableFromClipboard>>;
|
|
try {
|
|
result = await actions.importTableFromClipboard(ctx);
|
|
} catch {
|
|
new Notice(t("notice.importFailed"));
|
|
return;
|
|
}
|
|
if (!result) {
|
|
new Notice(t("notice.importNoTable"));
|
|
return;
|
|
}
|
|
new Notice(
|
|
t("notice.importDone", {
|
|
source: result.source === "html" ? "HTML" : "TSV",
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Public hook used by the floating toolbar and right-click menu. */
|
|
openGridEditor(): void {
|
|
const ctx = getActionContext(this.app, this);
|
|
if (!ctx) {
|
|
new Notice(t("notice.notInTable"));
|
|
return;
|
|
}
|
|
const loc = locateTable(ctx.editor);
|
|
if (!loc) {
|
|
new Notice(t("notice.notInTable"));
|
|
return;
|
|
}
|
|
let model: TableModel;
|
|
try {
|
|
model = parseTable(loc.text).model;
|
|
} catch {
|
|
new Notice(t("notice.notInTable"));
|
|
return;
|
|
}
|
|
new GridEditorModal(this.app, model, (newModel) => {
|
|
actions.replaceTable(ctx, newModel);
|
|
}).open();
|
|
}
|
|
|
|
private warnIfConflictingPlugins(): void {
|
|
const enabled = (this.app as unknown as { plugins?: { enabledPlugins?: Set<string> } }).plugins?.enabledPlugins;
|
|
if (!enabled) return;
|
|
const found = CONFLICT_PLUGIN_IDS.filter((id) => enabled.has(id));
|
|
if (found.length === 0) return;
|
|
new Notice(t("notice.conflictPlugins", { plugins: found.join(", ") }), 8000);
|
|
}
|
|
}
|