diff --git a/main.ts b/main.ts index 0156eab..637d7de 100644 --- a/main.ts +++ b/main.ts @@ -4,6 +4,7 @@ import { ItemView, MarkdownView, Menu, + Modal, Notice, Plugin, PluginSettingTab, @@ -24,18 +25,30 @@ import { ViewUpdate, WidgetType, } from "@codemirror/view"; -import { Configuration as AzureConfiguration, OpenAIApi as AzureOpenAiApi} from "azure-openai"; +import { + Configuration as AzureConfiguration, + OpenAIApi as AzureOpenAiApi, +} from "azure-openai"; import * as cohere from "cohere-ai"; import * as fs from "fs"; import GPT3Tokenizer from "gpt3-tokenizer"; import { Configuration, OpenAIApi } from "openai"; import { v4 as uuidv4 } from "uuid"; +import { DataSet, Edge, Network } from "vis-network/standalone"; const dialog = require("electron").remote.dialog; const untildify = require("untildify") as any; const tokenizer = new GPT3Tokenizer({ type: "codex" }); // TODO depends on model -const PROVIDERS = ["cohere", "textsynth", "ocp", "openai", "openai-chat", "azure", "azure-chat"]; +const PROVIDERS = [ + "cohere", + "textsynth", + "ocp", + "openai", + "openai-chat", + "azure", + "azure-chat", +]; type Provider = (typeof PROVIDERS)[number]; interface LoomSettings { @@ -55,6 +68,7 @@ interface LoomSettings { temperature: number; topP: number; n: number; + bestOf: number; showSettings: boolean; showNodeBorders: boolean; @@ -84,6 +98,7 @@ const DEFAULT_SETTINGS: LoomSettings = { temperature: 1, topP: 1, n: 5, + bestOf: 25, showSettings: false, showNodeBorders: false, @@ -95,6 +110,7 @@ type Color = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | null; interface Node { text: string; parentId: string | null; + parentIds: (string | null)[]; unread: boolean; lastVisited?: number; collapsed: boolean; @@ -107,6 +123,7 @@ interface NoteState { hoisted: string[]; nodes: Record; generating: string | null; + infillMode: boolean; } export default class LoomPlugin extends Plugin { @@ -126,21 +143,37 @@ export default class LoomPlugin extends Plugin { } renderViews() { - const views = this.app.workspace.getLeavesOfType("loom").map((leaf) => leaf.view) as LoomView[]; - views.forEach((view) => view.render()); + const views = this.app.workspace + .getLeavesOfType("loom") + .map((leaf) => leaf.view) as LoomView[]; + views.forEach((view) => view.render()); } renderSiblingsViews() { - const views = this.app.workspace.getLeavesOfType("loom-siblings").map((leaf) => leaf.view) as LoomSiblingsView[]; - views.forEach((view) => view.render()); + const views = this.app.workspace + .getLeavesOfType("loom-siblings") + .map((leaf) => leaf.view) as LoomSiblingsView[]; + views.forEach((view) => view.render()); + } + + renderGraphViews() { + const views = this.app.workspace + .getLeavesOfType("loom-graph") + .map((leaf) => leaf.view) as LoomGraphView[]; + views.forEach((view) => view.render()); + } + + renderAllViews() { + this.renderViews(); + this.renderSiblingsViews(); + this.renderGraphViews(); } thenSaveAndRender(callback: () => void) { callback(); this.save(); - this.renderViews(); - this.renderSiblingsViews(); + this.renderAllViews(); } wftsar(callback: (file: TFile) => void) { @@ -161,20 +194,18 @@ export default class LoomPlugin extends Plugin { } setAzureOpenAI() { - if (!this.settings.azureApiKey || !this.settings.azureEndpoint) return; + if (!this.settings.azureApiKey || !this.settings.azureEndpoint) return; const configuration = new AzureConfiguration({ apiKey: this.settings.azureApiKey, - // add azure info into configuration azure: { apiKey: this.settings.azureApiKey, - endpoint: this.settings.azureEndpoint - } + endpoint: this.settings.azureEndpoint, + }, }); this.azure = new AzureOpenAiApi(configuration); } - async onload() { await this.loadSettings(); await this.loadState(); @@ -189,10 +220,11 @@ export default class LoomPlugin extends Plugin { this.statusBarItem.setText("Completing..."); this.statusBarItem.style.display = "none"; - const complete = (checking: boolean, siblings: boolean) => { + const complete = (checking: boolean, type: "normal" | "siblings" | "infill") => { const file = this.app.workspace.getActiveFile(); if (!file) return false; - if (!["md", "canvas"].contains(file.extension)) return false; + if (!["md", "canvas"].contains(file.extension)) return false; + if (type === "infill" && !this.state[file.path].infillMode) return false; // only check if api keys are set, not if they're valid, because that sometimes requires additional api calls if ( @@ -216,39 +248,50 @@ export default class LoomPlugin extends Plugin { return false; if (!checking) { - if (file.extension === "md") - this.mdComplete(file, siblings); - else if (file.extension === "canvas") - this.canvasComplete(siblings); - } + if (file.extension === "md") this.mdComplete(file, type); + else if (file.extension === "canvas" && type === "normal") this.canvasComplete(); + else { + new Notice("Not supported in canvas view"); + return false; + } + } return true; - } + }; this.addCommand({ id: "complete", name: "Complete from current point", icon: "wand", - checkCallback: (checking: boolean) => complete(checking, false), + checkCallback: (checking: boolean) => complete(checking, "normal"), hotkeys: [{ modifiers: ["Ctrl"], key: " " }], }); + this.addCommand({ + id: "generate-siblings", + name: "Generate siblings", + icon: "wand", + checkCallback: (checking: boolean) => complete(checking, "siblings"), + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }], + }); + this.addCommand({ - id: "generate-siblings", - name: "Generate siblings", + id: "infill", + name: "Infill at current point", icon: "wand", - checkCallback: (checking: boolean) => complete(checking, true), - hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }], + checkCallback: (checking: boolean) => complete(checking, "infill"), + hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: " " }], }); const withState = ( checking: boolean, callback: (state: NoteState) => void, - canvasCallback?: () => boolean, + canvasCallback?: () => boolean ) => { const file = this.app.workspace.getActiveFile(); if (!file) return false; - if (file.extension === "canvas" && canvasCallback) return canvasCallback(); - if (file.extension !== "md") return false; + if (file.extension === "canvas" && canvasCallback) + return canvasCallback(); + if (file.extension !== "md") return false; const state = this.state[file.path]; if (!state) this.initializeFile(file); @@ -261,12 +304,13 @@ export default class LoomPlugin extends Plugin { checking: boolean, checkCallback: (state: NoteState) => boolean, callback: (state: NoteState) => void, - canvasCallback?: () => boolean, + canvasCallback?: () => boolean ) => { const file = this.app.workspace.getActiveFile(); if (!file) return false; - if (file.extension === "canvas" && canvasCallback) return canvasCallback(); - if (file.extension !== "md") return false; + if (file.extension === "canvas" && canvasCallback) + return canvasCallback(); + if (file.extension !== "md") return false; const state = this.state[file.path]; if (!state) this.initializeFile(file); @@ -281,9 +325,7 @@ export default class LoomPlugin extends Plugin { const loomPanes = this.app.workspace.getLeavesOfType("loom"); try { if (loomPanes.length === 0) - this.app.workspace - .getRightLeaf(false) - .setViewState({ type: "loom" }); + this.app.workspace.getRightLeaf(false).setViewState({ type: "loom" }); else if (focus) this.app.workspace.revealLeaf(loomPanes[0]); } catch (e) { console.error(e); @@ -303,6 +345,19 @@ export default class LoomPlugin extends Plugin { } }; + const openLoomGraphPane = (focus: boolean) => { + const loomPanes = this.app.workspace.getLeavesOfType("loom-graph"); + try { + if (loomPanes.length === 0) + this.app.workspace + .getRightLeaf(false) + .setViewState({ type: "loom-graph" }); + else if (focus) this.app.workspace.revealLeaf(loomPanes[0]); + } catch (e) { + console.error(e); + } + }; + this.addCommand({ id: "create-child", name: "Create child of current node", @@ -338,9 +393,13 @@ export default class LoomPlugin extends Plugin { id: "break-at-point", name: "Split at current point", checkCallback: (checking: boolean) => - withState(checking, (state) => { - this.app.workspace.trigger("loom:break-at-point", state.current); - }, () => this.canvasBreakAtPoint()), + withState( + checking, + (state) => { + this.app.workspace.trigger("loom:break-at-point", state.current); + }, + () => this.canvasBreakAtPoint() + ), hotkeys: [{ modifiers: ["Alt"], key: "c" }], }); @@ -503,16 +562,29 @@ export default class LoomPlugin extends Plugin { callback: () => openLoomPane(true), }); + this.addCommand({ + id: "open-siblings-pane", + name: "Open Loom siblings pane", + callback: () => openLoomSiblingsPane(true), + }); + + this.addCommand({ + id: "open-graph-pane", + name: "Open Loom graph pane", + callback: () => openLoomGraphPane(true), + }); + this.addCommand({ id: "debug-reset-state", name: "Debug: Reset state", callback: () => this.thenSaveAndRender(() => (this.state = {})), }); - const getState = () => this.withFile((file) => { - if (file.extension === "canvas") return "canvas"; - return this.state[file.path]; - }); + const getState = () => + this.withFile((file) => { + if (file.extension === "canvas") return "canvas"; + return this.state[file.path]; + }); const getSettings = () => this.settings; this.registerView( @@ -525,6 +597,11 @@ export default class LoomPlugin extends Plugin { (leaf) => new LoomSiblingsView(leaf, getState) ); + this.registerView( + "loom-graph", + (leaf) => new LoomGraphView(leaf, getState) + ); + const loomEditorPlugin = ViewPlugin.fromClass( LoomEditorPlugin, loomEditorPluginSpec @@ -533,6 +610,7 @@ export default class LoomPlugin extends Plugin { openLoomPane(true); openLoomSiblingsPane(false); + openLoomGraphPane(false); this.registerEvent( this.app.workspace.on( @@ -553,6 +631,7 @@ export default class LoomPlugin extends Plugin { hoisted: [] as string[], nodes: {}, generating: null, + infillMode: false, }; // if this note has no current node, set it to the editor's text and return @@ -562,6 +641,7 @@ export default class LoomPlugin extends Plugin { this.state[view.file.path].nodes[current] = { text: editor.getValue(), parentId: null, + parentIds: [null], unread: false, collapsed: false, bookmarked: false, @@ -682,10 +762,30 @@ export default class LoomPlugin extends Plugin { const ch = this.editor.getLine(line).length; this.editor.setCursor({ line, ch }); } else this.editor.setCursor(cursor); + + // switch all children's `parentId` to this node + Object.entries(this.state[file.path].nodes) + .filter(([, node]) => node.parentIds.includes(this.state[file.path].current)) + .forEach(([id,]) => + this.state[file.path].nodes[id].parentId = this.state[file.path].current + ); }) ) ); + this.registerEvent( + // @ts-expect-error + this.app.workspace.on("loom:enable-infill-mode", () => { + this.wftsar((file) => { + this.state[file.path].infillMode = true + // migrate old nodes, which don't have `parentIds` + Object.keys(this.state[file.path].nodes).forEach((id) => { + this.state[file.path].nodes[id].parentIds = [this.state[file.path].nodes[id].parentId]; + }) + } + )}) + ); + this.registerEvent( // @ts-expect-error this.app.workspace.on("loom:toggle-collapse", (id: string) => @@ -737,6 +837,7 @@ export default class LoomPlugin extends Plugin { this.state[file.path].nodes[newId] = { text: "", parentId: id, + parentIds: [id], unread: false, collapsed: false, bookmarked: false, @@ -756,6 +857,7 @@ export default class LoomPlugin extends Plugin { this.state[file.path].nodes[newId] = { text: "", parentId: this.state[file.path].nodes[id].parentId, + parentIds: [this.state[file.path].nodes[id].parentId], unread: false, collapsed: false, bookmarked: false, @@ -775,6 +877,7 @@ export default class LoomPlugin extends Plugin { this.state[file.path].nodes[newId] = { text: this.state[file.path].nodes[id].text, parentId: this.state[file.path].nodes[id].parentId, + parentIds: [this.state[file.path].nodes[id].parentId], unread: false, collapsed: false, bookmarked: false, @@ -790,13 +893,16 @@ export default class LoomPlugin extends Plugin { // @ts-expect-error this.app.workspace.on("loom:break-at-point", () => this.withFile((file) => { - const parentId = this.breakAtPoint(); + const split = this.breakAtPoint(); + if (!split) return; + const [parentId,] = split; if (parentId !== undefined) { const newId = uuidv4(); this.state[file.path].nodes[newId] = { text: "", parentId, + parentIds: [parentId], unread: false, collapsed: false, bookmarked: false, @@ -859,17 +965,19 @@ export default class LoomPlugin extends Plugin { (id_) => id_ !== id ); - let fallback - const siblings = Object.entries(this.state[file.path].nodes).filter( - ([_id, node]) => node.parentId === this.state[file.path].nodes[id].parentId && id !== _id - ); - const byLastVisited = siblings.sort(([_, a], [__, b]) => { - if (a.lastVisited === undefined) return 1; - if (b.lastVisited === undefined) return -1; - return b.lastVisited - a.lastVisited; - }); - if (byLastVisited.length > 0) fallback = byLastVisited[0][0]; - else fallback = this.state[file.path].nodes[id].parentId; + let fallback; + const siblings = Object.entries(this.state[file.path].nodes).filter( + ([_id, node]) => + node.parentId === this.state[file.path].nodes[id].parentId && + id !== _id + ); + const byLastVisited = siblings.reverse().sort(([_, a], [__, b]) => { + if (a.lastVisited === undefined) return -1; + if (b.lastVisited === undefined) return 1; + return b.lastVisited - a.lastVisited; + }); + if (byLastVisited.length > 0) fallback = byLastVisited[0][0]; + else fallback = this.state[file.path].nodes[id].parentId; let deleted = [id]; @@ -978,10 +1086,9 @@ export default class LoomPlugin extends Plugin { this.registerEvent( this.app.workspace.on("file-open", (file) => { if (!file) return; - if (file.extension !== "md") return; + if (file.extension !== "md") return; - this.renderViews(); - this.renderSiblingsViews(); + this.renderAllViews(); this.app.workspace.iterateRootLeaves((leaf) => { if ( @@ -1034,8 +1141,7 @@ export default class LoomPlugin extends Plugin { this.registerEvent( this.app.workspace.on("resize", () => { - this.renderViews(); - this.renderSiblingsViews(); + this.renderAllViews(); }) ); @@ -1078,6 +1184,7 @@ export default class LoomPlugin extends Plugin { this.state[file.path].nodes[id] = { text, parentId: null, + parentIds: [null], unread: false, collapsed: false, bookmarked: false, @@ -1088,7 +1195,7 @@ export default class LoomPlugin extends Plugin { this.thenSaveAndRender(() => {}); } - async complete(prompt: string) { + async complete(prompt: string, suffix?: string) { this.statusBarItem.style.display = "inline-flex"; // remove a trailing space if there is one @@ -1097,9 +1204,9 @@ export default class LoomPlugin extends Plugin { prompt = prompt.replace(/\s+$/, ""); // replace "\<" with "<", because obsidian tries to render html tags - // and "\[" with "[" + // and "\[" with "[" prompt = prompt.replace(/\\ choice.text - ); + rawCompletions = response.json.choices.map((choice: any) => choice.text); } if (rawCompletions === undefined) { @@ -1262,141 +1373,169 @@ export default class LoomPlugin extends Plugin { for (let completion of rawCompletions) { if (!completion) completion = ""; // empty completions are null, apparently completion = completion.replace(/ id !== rootNode); + } else { + const prompt = this.fullText(rootNode, state); + + const completions = await this.complete(prompt); + if (!completions) return; + + // create a child node to the current node for each completion + for (let completion of completions) { + const id = uuidv4(); + state.nodes[id] = { + text: completion, + parentId: state.generating, + parentIds: [state.generating], + unread: true, + collapsed: false, + bookmarked: false, + color: null, + }; + ids.push(id); + } + } // switch to the first completion this.app.workspace.trigger("loom:switch-to", ids[0]); this.state[file.path].generating = null; this.save(); - this.renderViews(); - this.renderSiblingsViews(); + this.renderAllViews(); this.statusBarItem.style.display = "none"; } - async canvasComplete(siblings: boolean) { - if (siblings) { - new Notice("Not yet supported in canvas view"); - return; - } + async canvasComplete() { + new Notice("Generating..."); - new Notice("Generating..."); + // @ts-expect-error + const canvas = this.app.workspace.getActiveViewOfType(ItemView).canvas; - // @ts-expect-error - const canvas = this.app.workspace.getActiveViewOfType(ItemView).canvas; - const onlySetMember = (set: Set) => { if (set.size !== 1) { - new Notice("Node has multiple parents"); - throw new Error("Set has more than one member"); - } - return set.values().next().value; - } + new Notice("Node has multiple parents"); + throw new Error("Set has more than one member"); + } + return set.values().next().value; + }; - canvas.selection.forEach(async (node: any) => { - let text - let childNodes: any[] = []; + canvas.selection.forEach(async (node: any) => { + let text; + let childNodes: any[] = []; - if (node.isEditing) { + if (node.isEditing) { const editor = node.child.editor; - const editorValue = editor.getValue(); - const lines = editorValue.split("\n"); - const cursor = editor.getCursor(); + const editorValue = editor.getValue(); + const lines = editorValue.split("\n"); + const cursor = editor.getCursor(); - text = [...lines.slice(0, cursor.line), lines[cursor.line].slice(0, cursor.ch)].join("\n"); - editor.setValue(text); + text = [ + ...lines.slice(0, cursor.line), + lines[cursor.line].slice(0, cursor.ch), + ].join("\n"); + editor.setValue(text); const after = editorValue.slice(text.length); - const childNode = await this.canvasCreateChildNode(canvas, node, after); - childNodes.push(childNode.id); - } else text = node.text; - let currentNode = canvas.edgeTo.data.get(node); - if (currentNode !== undefined) currentNode = onlySetMember(currentNode).from.node; - while (currentNode) { - text = currentNode.text + text; - currentNode = canvas.edgeTo.data.get(currentNode); - if (currentNode !== undefined) currentNode = onlySetMember(currentNode).from.node; - } + const childNode = await this.canvasCreateChildNode(canvas, node, after); + childNodes.push(childNode.id); + } else text = node.text; + let currentNode = canvas.edgeTo.data.get(node); + if (currentNode !== undefined) + currentNode = onlySetMember(currentNode).from.node; + while (currentNode) { + text = currentNode.text + text; + currentNode = canvas.edgeTo.data.get(currentNode); + if (currentNode !== undefined) + currentNode = onlySetMember(currentNode).from.node; + } - const completions = await this.complete(text); - if (!completions) return; + const completions = await this.complete(text); + if (!completions) return; - for (let i = 0; i < completions.length; i++) { - const completion = completions[i]; - const childNode = await this.canvasCreateChildNode(canvas, node, completion); - childNodes.push(childNode.id); - } + for (let i = 0; i < completions.length; i++) { + const completion = completions[i]; + const childNode = await this.canvasCreateChildNode( + canvas, + node, + completion + ); + childNodes.push(childNode.id); + } - // adjust the y positions of the child nodes - const data = canvas.getData(); - const reversedNodes = [...data.nodes].reverse(); - let y = node.y; - canvas.importData({ - edges: data.edges, - nodes: reversedNodes.map((node: any) => { - if (childNodes.includes(node.id)) { - node.y = y; - y += node.height + 50; - } - return node; - } - )}); + // adjust the y positions of the child nodes + const data = canvas.getData(); + const reversedNodes = [...data.nodes].reverse(); + let y = node.y; + canvas.importData({ + edges: data.edges, + nodes: reversedNodes.map((node: any) => { + if (childNodes.includes(node.id)) { + node.y = y; + y += node.height + 50; + } + return node; + }), + }); - canvas.deselectAll(); - childNodes.forEach((id: string) => canvas.select(canvas.nodes.get(id))); - }); + canvas.deselectAll(); + childNodes.forEach((id: string) => canvas.select(canvas.nodes.get(id))); + }); } fullText(id: string | null, state: NoteState) { @@ -1461,7 +1600,7 @@ export default class LoomPlugin extends Plugin { return children[0][0]; } - breakAtPoint(): string | null | undefined { + breakAtPoint(): (string | null | undefined)[] | null { return this.withFile((file) => { // split the current node into: // - parent node with text before cursor @@ -1495,12 +1634,12 @@ export default class LoomPlugin extends Plugin { n++; } - // if cursor is at the beginning of the node, create a sibling + // if cursor is at the beginning of the node, create a sibling TODO ??? wait what if (i === 0) { - return null; + return [undefined, current]; // if cursor is at the end of the node, create a child } else if (end) { - return current; + return [current, undefined]; } const inRangeNode = family[n]; @@ -1524,6 +1663,7 @@ export default class LoomPlugin extends Plugin { this.state[file.path].nodes[afterId] = { text: after, parentId: inRangeNode, + parentIds: [inRangeNode], unread: false, collapsed: false, bookmarked: false, @@ -1533,69 +1673,81 @@ export default class LoomPlugin extends Plugin { // move the children to under the after node children.forEach((child) => (child.parentId = afterId)); - return inRangeNode; + return [inRangeNode, afterId]; }); } canvasBreakAtPoint(): boolean { - const view = this.app.workspace.getActiveViewOfType(ItemView); - if (!view) return false; - // @ts-expect-error - const canvas = view.canvas; + const view = this.app.workspace.getActiveViewOfType(ItemView); + if (!view) return false; + // @ts-expect-error + const canvas = view.canvas; - canvas.selection.forEach((node: any) => { - if (!node.isEditing) return; + canvas.selection.forEach((node: any) => { + if (!node.isEditing) return; const editor = node.child.editor; - const text = editor.getValue(); - const lines = text.split("\n"); - const cursor = editor.getCursor(); + const text = editor.getValue(); + const lines = text.split("\n"); + const cursor = editor.getCursor(); - const before = [...lines.slice(0, cursor.line), lines[cursor.line].slice(0, cursor.ch)].join("\n"); + const before = [ + ...lines.slice(0, cursor.line), + lines[cursor.line].slice(0, cursor.ch), + ].join("\n"); const after = text.slice(before.length); - editor.setValue(before); - editor.setCursor({line: cursor.line, ch: cursor.ch - 1}); + editor.setValue(before); + editor.setCursor({ line: cursor.line, ch: cursor.ch - 1 }); - this.canvasCreateChildNode(canvas, node, after); - }); + this.canvasCreateChildNode(canvas, node, after); + }); - return true; + return true; } async canvasCreateChildNode(canvas: any, node: any, childText: string) { const childNode = canvas.createTextNode({ - pos: { x: node.x + node.width + 50, y: node.y }, - size: { width: 300, height: 100 }, - text: childText, - save: true, - focus: false, - }); + pos: { x: node.x + node.width + 50, y: node.y }, + size: { width: 300, height: 100 }, + text: childText, + save: true, + focus: false, + }); - const data = canvas.getData(); - canvas.importData({ - edges: [...data.edges, { id: uuidv4(), fromNode: node.id, fromSide: "right", toNode: childNode.id, toSide: "left" }], - nodes: data.nodes, - }); - canvas.requestFrame(); + const data = canvas.getData(); + canvas.importData({ + edges: [ + ...data.edges, + { + id: uuidv4(), + fromNode: node.id, + fromSide: "right", + toNode: childNode.id, + toSide: "left", + }, + ], + nodes: data.nodes, + }); + canvas.requestFrame(); - await new Promise(r => setTimeout(r, 50)); // wait for the element to render + await new Promise((r) => setTimeout(r, 50)); // wait for the element to render - const element = childNode.nodeEl; - const sizer = element.querySelector(".markdown-preview-sizer"); - const height = sizer.getBoundingClientRect().height; + const element = childNode.nodeEl; + const sizer = element.querySelector(".markdown-preview-sizer"); + const height = sizer.getBoundingClientRect().height; - const data_ = canvas.getData(); - canvas.importData({ - edges: data_.edges, - nodes: data_.nodes.map((node: any) => { - if (node.id === childNode.id) node.height = height / canvas.scale + 52; - return node; - } - )}); - canvas.requestFrame(); + const data_ = canvas.getData(); + canvas.importData({ + edges: data_.edges, + nodes: data_.nodes.map((node: any) => { + if (node.id === childNode.id) node.height = height / canvas.scale + 52; + return node; + }), + }); + canvas.requestFrame(); - return childNode; + return childNode; } async loadSettings() { @@ -1676,7 +1828,7 @@ class LoomView extends ItemView { "Show node borders in the editor" ); - if (state !== "canvas") { + if (state !== "canvas") { const importFileInput = navButtonsContainer.createEl("input", { cls: "hidden", attr: { type: "file", id: "loom-import" }, @@ -1716,7 +1868,26 @@ class LoomView extends ItemView { this.app.workspace.trigger("loom:export", result.filePath); }); }); - } + } + + const infillMode = state === "canvas" ? false : state?.infillMode; + const infillModeButton = navButtonsContainer.createDiv({ + cls: `clickable-icon nav-action-button${infillMode ? " is-active" : ""}`, + attr: { "aria-label": "Enable infill mode" }, + }); + setIcon(infillModeButton, "share-2"); + infillModeButton.addEventListener("click", () => { + if (!state) return; + if (state === "canvas") { + new Notice("Not available in canvas mode"); + return; + } + if (state?.infillMode) { + new Notice("Infill mode is already enabled"); + return; + } + new InfillModeModal(this.app).open(); + }); // create the main container, which uses the `outline` class, which has // a margin visually consistent with other panes @@ -1838,6 +2009,16 @@ class LoomView extends ItemView { "number", (value) => parseInt(value) ); + if ([ "ocp", "openai", "azure" ].includes(settings.provider)) { + setting( + "Best of", + "loom-best-of", + "bestOf", + String(settings.bestOf), + "number", + (value) => parseInt(value) + ); + } // tree @@ -1848,13 +2029,13 @@ class LoomView extends ItemView { }); return; } - if (state === "canvas") { - container.createEl("div", { - cls: "pane-empty", - text: "The selected note is a canvas.", - }); - return; - } + if (state === "canvas") { + container.createEl("div", { + cls: "pane-empty", + text: "The selected note is a canvas.", + }); + return; + } const nodes = Object.entries(state.nodes); @@ -2149,6 +2330,15 @@ class LoomView extends ItemView { cls: "tree-item-inner loom-section-header-inner", }); + // if infill mode is enabled, this view can't be displayed + if (state.infillMode) { + container.createEl("div", { + text: "This view is not available in infill mode.", + cls: "pane-empty", + }); + return; + } + // if there is a hoisted node, it is the root node // otherwise, all children of `null` are the root nodes if (state.hoisted.length > 0) @@ -2185,10 +2375,35 @@ class LoomView extends ItemView { } } +class InfillModeModal extends Modal { + constructor(app: App) { + super(app); + } + + onOpen() { + let { contentEl } = this; + contentEl.createEl("p", { text: "Are you sure you want to enable infill mode?" }); + contentEl.createEl("p", { text: "Once enabled, you will not be able to disable it. Infill mode allows you to create infills, but disables the default tree view." }); + + new Setting(contentEl) + .addButton((button) => { + button.setButtonText("Enable infill mode"); + button.onClick(async () => { + this.app.workspace.trigger("loom:enable-infill-mode"); + this.close(); + }); + } + ); + } +} + class LoomSiblingsView extends ItemView { getNoteState: () => NoteState | "canvas" | null; - constructor(leaf: WorkspaceLeaf, getNoteState: () => NoteState | "canvas" | null) { + constructor( + leaf: WorkspaceLeaf, + getNoteState: () => NoteState | "canvas" | null + ) { super(leaf); this.getNoteState = getNoteState; this.render(); @@ -2210,13 +2425,13 @@ class LoomSiblingsView extends ItemView { }); return; } - if (state === "canvas") { - outline.createEl("div", { - text: "The selected note is a canvas.", - cls: "pane-empty", - }); - return; - } + if (state === "canvas") { + outline.createEl("div", { + text: "The selected note is a canvas.", + cls: "pane-empty", + }); + return; + } const parentId = state.nodes[state.current].parentId; const siblings = Object.entries(state.nodes).filter( @@ -2268,6 +2483,130 @@ class LoomSiblingsView extends ItemView { } } +class LoomGraphView extends ItemView { + getNoteState: () => NoteState | "canvas" | null; + x: number; + y: number; + + constructor( + leaf: WorkspaceLeaf, + getNoteState: () => NoteState | "canvas" | null + ) { + super(leaf); + this.getNoteState = getNoteState; + this.x = 0; + this.y = 0; + this.render(); + } + + render() { + const state = this.getNoteState(); + if (!state) return; + if (state === "canvas") return; + + const nodes = new DataSet( + Object.entries(state.nodes).map(([id, node]) => ({ + id, + title: node.text, + color: state.current === id ? "#fff" : "#888", + label: node.unread ? "•" : "", + })) + ); + + // TODO gross + let ancestors: (string | null)[] = [state.current]; + let current = state.current; + while (true) { + const parent = state.nodes[current].parentId; + if (!parent) break; + current = parent; + ancestors.push(current); + } + ancestors.push(null); + + const ancestorEdge = (from: string, to: string) => ancestors.includes(from) && ancestors.includes(to); + + let edgeArray: Edge[]; + if (state.infillMode) { + edgeArray = []; + for (const [id, node] of Object.entries(state.nodes)) + for (const parentId of node.parentIds!) { + if (parentId) + edgeArray.push({ + from: parentId, + to: id, + color: ancestorEdge(parentId, id) ? "#fff" : "#888", + width: ancestorEdge(parentId, id) ? 3 : 2, + }); + } + } else + edgeArray = Object.entries(state.nodes) + .filter(([, node]) => node.parentId) + .map(([id, node]) => ({ + from: node.parentId as string, + to: id, + color: + ancestorEdge(node.parentId as string, id) + ? "#fff" + : "#666", + width: ancestorEdge(node.parentId as string, id) ? 3 : 2, + })); + const edges = new DataSet(edgeArray); + + const data = { nodes, edges }; + const options = { + layout: { + improvedLayout: false, + hierarchical: { + sortMethod: "directed", + direction: "UD", + shakeTowards: "roots", + }, + }, + nodes: { + borderWidth: 0, + borderWidthSelected: 0, + chosen: false, + shape: "dot", + font: { + color: "#fff", + size: 30, + }, + }, + edges: { chosen: false, color: "#888" }, + physics: { enabled: false }, + }; + + this.containerEl.empty(); + + const network = new Network(this.containerEl, data, options); + network.moveTo({ position: { x: this.x, y: this.y }, scale: 0.3 }); + + network.on("click", (e) => { + if (e.nodes.length === 0) return; + this.app.workspace.trigger("loom:switch-to", e.nodes[0]); + }); + + network.on("dragEnd", (_) => { + let { x, y } = network.getViewPosition(); + this.x = x; + this.y = y; + }); + } + + getViewType(): string { + return "loom-graph"; + } + + getDisplayText(): string { + return "Graph"; + } + + getIcon(): string { + return "share-2"; + } +} + interface LoomEditorPluginState { nodeLengths: [string, number][]; showNodeBorders: boolean; @@ -2451,18 +2790,19 @@ class LoomSettingTab extends PluginSettingTab { ); apiKeySetting("OpenAI", "openaiApiKey"); - apiKeySetting("Azure", "azureApiKey") + apiKeySetting("Azure", "azureApiKey"); new Setting(containerEl) - .setName("Azure resource endpoint") - .setDesc("Required if using Azure") - .addText((text) => - text.setValue(this.plugin.settings.azureEndpoint).onChange(async (value) => { - this.plugin.settings.azureEndpoint = value; - await this.plugin.save(); - }) - ); - + .setName("Azure resource endpoint") + .setDesc("Required if using Azure") + .addText((text) => + text + .setValue(this.plugin.settings.azureEndpoint) + .onChange(async (value) => { + this.plugin.settings.azureEndpoint = value; + await this.plugin.save(); + }) + ); // TODO: reduce duplication of other settings diff --git a/package-lock.json b/package-lock.json index e2ea826..9bb42b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-loom", - "version": "1.6.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-loom", - "version": "1.6.0", + "version": "1.8.0", "license": "MIT", "dependencies": { "@codemirror/state": "^6.2.0", @@ -17,7 +17,8 @@ "gpt3-tokenizer": "^1.1.5", "openai": "^3.2.0", "untildify": "^4.0.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "vis-network": "^9.1.6" }, "devDependencies": { "@types/node": "^16.11.6", @@ -47,6 +48,18 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "peer": true, + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@esbuild/linux-arm64": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz", @@ -178,6 +191,12 @@ "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", "dev": true }, + "node_modules/@types/hammerjs": { + "version": "2.0.41", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.41.tgz", + "integrity": "sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA==", + "peer": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -606,6 +625,12 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "peer": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1309,6 +1334,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/keycharm": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz", + "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==", + "peer": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1773,6 +1804,12 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==", + "peer": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1874,6 +1911,57 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vis-data": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.6.tgz", + "integrity": "sha512-lG7LJdkawlKSXsdcEkxe/zRDyW29a4r7N7PMwxCPxK12/QIdqxJwcMxwjVj9ozdisRhP5TyWDHZwsgjmj0g6Dg==", + "hasInstallScript": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "vis-util": "^5.0.1" + } + }, + "node_modules/vis-network": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.6.tgz", + "integrity": "sha512-Eiwx1JleAsUqfy4pzcsFngCVlCEdjAtRPB/OwCV7PHBm+o2jtE4IZPcPITAEGUlxvL4Fdw7/lZsfD32dL+IL6g==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0", + "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0", + "timsort": "^0.3.0", + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "vis-data": "^6.3.0 || ^7.0.0", + "vis-util": "^5.0.1" + } + }, + "node_modules/vis-util": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.3.tgz", + "integrity": "sha512-Wf9STUcFrDzK4/Zr7B6epW2Kvm3ORNWF+WiwEz2dpf5RdWkLUXFSbLcuB88n1W6tCdFwVN+v3V4/Xmn9PeL39g==", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz", diff --git a/package.json b/package.json index ae428c9..fec78d6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "gpt3-tokenizer": "^1.1.5", "openai": "^3.2.0", "untildify": "^4.0.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "vis-network": "^9.1.6" } } diff --git a/styles.css b/styles.css index bd034ba..094196b 100644 --- a/styles.css +++ b/styles.css @@ -145,6 +145,7 @@ overflow: hidden; text-overflow: ellipsis; padding: 0.35em 0 0.35em 0.6em; + flex-grow: 1; } .loom-node-bookmark {