mirror of
https://github.com/rosemary1812/Sidecar-Notes.git
synced 2026-07-22 14:30:26 +00:00
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
import { ItemView, MarkdownRenderer, WorkspaceLeaf, setIcon } from "obsidian";
|
|
import type { ExcerptEntry, SidecarSettings, WorkbenchData } from "./settings";
|
|
|
|
export const SIDECAR_VIEW_TYPE = "sidecar-excerpt-workbench";
|
|
|
|
export interface SidecarViewController {
|
|
addNoteEntry(): void;
|
|
exportMarkdown(): Promise<void>;
|
|
updateEntry(id: string, patch: Partial<Pick<ExcerptEntry, "quote" | "note">>): void;
|
|
deleteEntry(id: string): Promise<void>;
|
|
deleteNote(id: string): void;
|
|
handleWorkbenchClosed(): void;
|
|
setExcerptMode(enabled: boolean): void;
|
|
}
|
|
|
|
interface EntryElements {
|
|
root: HTMLElement;
|
|
quoteTextarea: HTMLTextAreaElement | null;
|
|
noteTextarea: HTMLTextAreaElement | null;
|
|
}
|
|
|
|
export class SidecarView extends ItemView {
|
|
private controller: SidecarViewController | null = null;
|
|
private workbench: WorkbenchData = { entries: [] };
|
|
private settings: SidecarSettings | null = null;
|
|
private title = "Sidecar Notes";
|
|
private excerptMode = true;
|
|
private listEl: HTMLElement | null = null;
|
|
private modeButton: HTMLButtonElement | null = null;
|
|
private emptyEl: HTMLElement | null = null;
|
|
private entryEls = new Map<string, EntryElements>();
|
|
private editingQuotes = new Set<string>();
|
|
private editingNotes = new Set<string>();
|
|
private expandedQuotes = new Set<string>();
|
|
|
|
constructor(leaf: WorkspaceLeaf) {
|
|
super(leaf);
|
|
}
|
|
|
|
getViewType(): string {
|
|
return SIDECAR_VIEW_TYPE;
|
|
}
|
|
|
|
getDisplayText(): string {
|
|
return this.title;
|
|
}
|
|
|
|
getIcon(): string {
|
|
return "quote";
|
|
}
|
|
|
|
setController(controller: SidecarViewController): void {
|
|
this.controller = controller;
|
|
}
|
|
|
|
setWorkbench(
|
|
workbench: WorkbenchData,
|
|
title: string,
|
|
excerptMode: boolean,
|
|
settings: SidecarSettings
|
|
): void {
|
|
this.workbench = workbench;
|
|
this.title = title;
|
|
this.excerptMode = excerptMode;
|
|
this.settings = settings;
|
|
this.render();
|
|
}
|
|
|
|
applySettings(settings: SidecarSettings): void {
|
|
this.settings = settings;
|
|
this.contentEl.style.setProperty("--sidecar-preview-font-size", `${settings.summaryFontSize}px`);
|
|
}
|
|
|
|
addEntry(entry: ExcerptEntry): void {
|
|
if (!this.workbench.entries.some((item) => item.id === entry.id)) {
|
|
this.workbench.entries.push(entry);
|
|
}
|
|
if (entry.kind === "note" && !entry.note.trim()) {
|
|
this.editingNotes.add(entry.id);
|
|
}
|
|
this.appendEntry(entry);
|
|
this.updateEmptyState();
|
|
}
|
|
|
|
updateExcerptMode(enabled: boolean): void {
|
|
this.excerptMode = enabled;
|
|
this.updateModeButton();
|
|
}
|
|
|
|
removeEntry(id: string): void {
|
|
this.entryEls.get(id)?.root.remove();
|
|
this.entryEls.delete(id);
|
|
this.editingQuotes.delete(id);
|
|
this.editingNotes.delete(id);
|
|
this.expandedQuotes.delete(id);
|
|
this.workbench.entries = this.workbench.entries.filter((entry) => entry.id !== id);
|
|
this.updateEmptyState();
|
|
}
|
|
|
|
onOpen(): Promise<void> {
|
|
this.render();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
onClose(): Promise<void> {
|
|
this.controller?.handleWorkbenchClosed();
|
|
this.entryEls.clear();
|
|
this.editingQuotes.clear();
|
|
this.editingNotes.clear();
|
|
this.expandedQuotes.clear();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
private render(): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.addClass("sidecar-workbench");
|
|
if (this.settings) {
|
|
this.applySettings(this.settings);
|
|
}
|
|
|
|
const headerEl = contentEl.createDiv({ cls: "sidecar-workbench__header" });
|
|
const titleGroup = headerEl.createDiv({ cls: "sidecar-workbench__title-group" });
|
|
titleGroup.createEl("span", { cls: "sidecar-workbench__eyebrow", text: "Excerpt workbench" });
|
|
titleGroup.createEl("h2", { text: this.title });
|
|
|
|
const actionsEl = headerEl.createDiv({ cls: "sidecar-workbench__actions" });
|
|
|
|
this.modeButton = actionsEl.createEl("button", { cls: "sidecar-mode-button" });
|
|
this.modeButton.addEventListener("click", () => {
|
|
this.controller?.setExcerptMode(!this.excerptMode);
|
|
});
|
|
this.updateModeButton();
|
|
|
|
const exportButton = actionsEl.createEl("button", {
|
|
cls: "sidecar-command-button",
|
|
text: "Sync",
|
|
});
|
|
exportButton.addEventListener("click", () => {
|
|
void this.controller?.exportMarkdown();
|
|
});
|
|
|
|
const addButton = actionsEl.createEl("button", {
|
|
cls: "sidecar-command-button",
|
|
text: "+ note",
|
|
});
|
|
addButton.addEventListener("click", () => {
|
|
this.controller?.addNoteEntry();
|
|
});
|
|
|
|
this.emptyEl = contentEl.createDiv({
|
|
cls: "sidecar-workbench__empty",
|
|
text: "Turn on excerpt mode, then select text in the source note.",
|
|
});
|
|
|
|
this.listEl = contentEl.createDiv({ cls: "sidecar-workbench__list" });
|
|
this.renderEntryList();
|
|
}
|
|
|
|
private renderEntryList(): void {
|
|
if (!this.listEl) return;
|
|
this.listEl.empty();
|
|
this.entryEls.clear();
|
|
|
|
for (const entry of this.workbench.entries) {
|
|
this.appendEntry(entry);
|
|
}
|
|
|
|
this.updateEmptyState();
|
|
}
|
|
|
|
private appendEntry(entry: ExcerptEntry): void {
|
|
if (!this.listEl) return;
|
|
|
|
if (entry.kind === "note") {
|
|
this.appendNoteEntry(entry);
|
|
return;
|
|
}
|
|
|
|
this.appendExcerptEntry(entry);
|
|
}
|
|
|
|
private appendExcerptEntry(entry: ExcerptEntry): void {
|
|
if (!this.listEl) return;
|
|
|
|
const root = this.listEl.createDiv({ cls: "sidecar-entry sidecar-entry--excerpt" });
|
|
root.dataset.entryId = entry.id;
|
|
|
|
const quoteTextarea = this.renderExcerptSection(root, entry);
|
|
const noteTextarea = this.renderExcerptNoteSection(root, entry);
|
|
|
|
this.entryEls.set(entry.id, {
|
|
root,
|
|
quoteTextarea,
|
|
noteTextarea,
|
|
});
|
|
}
|
|
|
|
private renderExcerptSection(root: HTMLElement, entry: ExcerptEntry): HTMLTextAreaElement | null {
|
|
const section = root.createDiv({ cls: "sidecar-section sidecar-section--excerpt" });
|
|
const header = section.createDiv({ cls: "sidecar-section__header" });
|
|
header.createEl("span", { cls: "sidecar-section__label", text: "Excerpt" });
|
|
const actions = header.createDiv({ cls: "sidecar-icon-actions" });
|
|
|
|
if (this.editingQuotes.has(entry.id)) {
|
|
const textarea = section.createEl("textarea", {
|
|
cls: "sidecar-entry__editor",
|
|
text: entry.quote,
|
|
});
|
|
textarea.rows = 5;
|
|
|
|
const editorActions = section.createDiv({ cls: "sidecar-editor-actions" });
|
|
editorActions.createEl("button", {
|
|
cls: "sidecar-editor-button sidecar-editor-button--primary",
|
|
text: "Save",
|
|
}).addEventListener("click", () => {
|
|
void this.controller?.updateEntry(entry.id, { quote: textarea.value });
|
|
this.editingQuotes.delete(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
editorActions.createEl("button", {
|
|
cls: "sidecar-editor-button",
|
|
text: "Cancel",
|
|
}).addEventListener("click", () => {
|
|
this.editingQuotes.delete(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
return textarea;
|
|
}
|
|
|
|
const editButton = this.createIconButton(actions, "pencil", "Edit excerpt");
|
|
editButton.addEventListener("click", () => {
|
|
this.editingQuotes.add(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
const deleteButton = this.createIconButton(actions, "trash-2", "Delete excerpt");
|
|
deleteButton.addEventListener("click", () => {
|
|
void this.controller?.deleteEntry(entry.id);
|
|
});
|
|
|
|
const isLongQuote = this.isLongQuote(entry.quote);
|
|
const isExpanded = this.expandedQuotes.has(entry.id);
|
|
const preview = section.createDiv({
|
|
cls: isLongQuote && !isExpanded
|
|
? "sidecar-entry__preview sidecar-entry__preview--collapsed"
|
|
: "sidecar-entry__preview",
|
|
});
|
|
void this.renderMarkdown(preview, entry.quote);
|
|
if (isLongQuote) {
|
|
section.createEl("button", {
|
|
cls: "sidecar-expand-button",
|
|
text: isExpanded ? "Less" : "More",
|
|
}).addEventListener("click", () => {
|
|
if (this.expandedQuotes.has(entry.id)) {
|
|
this.expandedQuotes.delete(entry.id);
|
|
} else {
|
|
this.expandedQuotes.add(entry.id);
|
|
}
|
|
this.renderEntryList();
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private renderExcerptNoteSection(root: HTMLElement, entry: ExcerptEntry): HTMLTextAreaElement | null {
|
|
if (!entry.note.trim() && !this.editingNotes.has(entry.id)) {
|
|
root.createEl("button", {
|
|
cls: "sidecar-add-note-button",
|
|
text: "+ add note",
|
|
}).addEventListener("click", () => {
|
|
this.editingNotes.add(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const section = root.createDiv({ cls: "sidecar-section sidecar-section--note" });
|
|
const header = section.createDiv({ cls: "sidecar-section__header" });
|
|
header.createEl("span", { cls: "sidecar-section__label", text: "Note" });
|
|
const actions = header.createDiv({ cls: "sidecar-icon-actions" });
|
|
|
|
if (this.editingNotes.has(entry.id)) {
|
|
const textarea = section.createEl("textarea", {
|
|
cls: "sidecar-entry__editor",
|
|
text: entry.note,
|
|
});
|
|
textarea.rows = 6;
|
|
|
|
const editorActions = section.createDiv({ cls: "sidecar-editor-actions" });
|
|
editorActions.createEl("button", {
|
|
cls: "sidecar-editor-button sidecar-editor-button--primary",
|
|
text: "Save",
|
|
}).addEventListener("click", () => {
|
|
entry.noteOpen = false;
|
|
void this.controller?.updateEntry(entry.id, { note: textarea.value });
|
|
this.editingNotes.delete(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
editorActions.createEl("button", {
|
|
cls: "sidecar-editor-button",
|
|
text: "Cancel",
|
|
}).addEventListener("click", () => {
|
|
this.editingNotes.delete(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
return textarea;
|
|
}
|
|
|
|
const editButton = this.createIconButton(actions, "pencil", "Edit note");
|
|
editButton.addEventListener("click", () => {
|
|
this.editingNotes.add(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
const deleteButton = this.createIconButton(actions, "trash-2", "Delete note");
|
|
deleteButton.addEventListener("click", () => {
|
|
entry.note = "";
|
|
entry.noteOpen = false;
|
|
this.controller?.deleteNote(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
|
|
const preview = section.createDiv({ cls: "sidecar-entry__preview" });
|
|
void this.renderMarkdown(preview, entry.note);
|
|
return null;
|
|
}
|
|
|
|
private appendNoteEntry(entry: ExcerptEntry): void {
|
|
if (!this.listEl) return;
|
|
|
|
const root = this.listEl.createDiv({ cls: "sidecar-entry sidecar-entry--note" });
|
|
root.dataset.entryId = entry.id;
|
|
|
|
const section = root.createDiv({ cls: "sidecar-section sidecar-section--standalone-note" });
|
|
const header = section.createDiv({ cls: "sidecar-section__header" });
|
|
header.createEl("span", { cls: "sidecar-section__label", text: "Note" });
|
|
const actions = header.createDiv({ cls: "sidecar-icon-actions" });
|
|
|
|
let noteTextarea: HTMLTextAreaElement | null = null;
|
|
if (this.editingNotes.has(entry.id)) {
|
|
noteTextarea = section.createEl("textarea", {
|
|
cls: "sidecar-entry__editor",
|
|
text: entry.note,
|
|
});
|
|
noteTextarea.rows = 7;
|
|
|
|
const editorActions = section.createDiv({ cls: "sidecar-editor-actions" });
|
|
editorActions.createEl("button", {
|
|
cls: "sidecar-editor-button sidecar-editor-button--primary",
|
|
text: "Save",
|
|
}).addEventListener("click", () => {
|
|
void this.controller?.updateEntry(entry.id, { note: noteTextarea?.value ?? "" });
|
|
this.editingNotes.delete(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
editorActions.createEl("button", {
|
|
cls: "sidecar-editor-button",
|
|
text: "Cancel",
|
|
}).addEventListener("click", () => {
|
|
this.editingNotes.delete(entry.id);
|
|
if (!entry.note.trim()) {
|
|
void this.controller?.deleteEntry(entry.id);
|
|
} else {
|
|
this.renderEntryList();
|
|
}
|
|
});
|
|
} else {
|
|
const editButton = this.createIconButton(actions, "pencil", "Edit note");
|
|
editButton.addEventListener("click", () => {
|
|
this.editingNotes.add(entry.id);
|
|
this.renderEntryList();
|
|
});
|
|
const deleteButton = this.createIconButton(actions, "trash-2", "Delete note");
|
|
deleteButton.addEventListener("click", () => {
|
|
void this.controller?.deleteEntry(entry.id);
|
|
});
|
|
|
|
const preview = section.createDiv({ cls: "sidecar-entry__preview" });
|
|
void this.renderMarkdown(preview, entry.note);
|
|
}
|
|
|
|
this.entryEls.set(entry.id, {
|
|
root,
|
|
quoteTextarea: null,
|
|
noteTextarea,
|
|
});
|
|
}
|
|
|
|
private async renderMarkdown(container: HTMLElement, markdown: string): Promise<void> {
|
|
container.empty();
|
|
const sourcePath = this.app.workspace.getActiveFile()?.path ?? "";
|
|
await MarkdownRenderer.render(this.app, markdown.trim() || " ", container, sourcePath, this);
|
|
}
|
|
|
|
private createIconButton(parent: HTMLElement, icon: string, label: string): HTMLButtonElement {
|
|
const button = parent.createEl("button", {
|
|
cls: "sidecar-icon-button",
|
|
attr: {
|
|
"aria-label": label,
|
|
title: label,
|
|
},
|
|
});
|
|
setIcon(button, icon);
|
|
return button;
|
|
}
|
|
|
|
private isLongQuote(quote: string): boolean {
|
|
return quote.length > 900 || quote.split("\n").length > 16;
|
|
}
|
|
|
|
private updateModeButton(): void {
|
|
if (!this.modeButton) return;
|
|
this.modeButton.setText(this.excerptMode ? "Excerpt mode: On" : "Excerpt mode: Off");
|
|
this.modeButton.toggleClass("is-active", this.excerptMode);
|
|
}
|
|
|
|
private updateEmptyState(): void {
|
|
if (!this.emptyEl) return;
|
|
this.emptyEl.toggle(this.workbench.entries.length === 0);
|
|
}
|
|
}
|