|
|
@@ -0,0 +1,185 @@
|
|
|
+/**
|
|
|
+ * Plain-DOM widget for rendering ::query{...} results inside a ProseMirror
|
|
|
+ * decoration. No Vue instance — just direct DOM manipulation.
|
|
|
+ *
|
|
|
+ * Lifecycle:
|
|
|
+ * 1. Constructor: creates container div, shows loading skeleton
|
|
|
+ * 2. Async query resolves: replaces skeleton with results or empty state
|
|
|
+ * 3. Destroy: cleans up any pending work
|
|
|
+ */
|
|
|
+import type { GraphService } from "~/services/graph"
|
|
|
+
|
|
|
+export interface QueryAttrs {
|
|
|
+ type?: string
|
|
|
+ tag?: string
|
|
|
+ property?: string
|
|
|
+ equals?: string
|
|
|
+}
|
|
|
+
|
|
|
+export interface QueryResult {
|
|
|
+ blockId: string
|
|
|
+ pagePath: string
|
|
|
+ title: string
|
|
|
+}
|
|
|
+
|
|
|
+const EMPTY_MESSAGES: Record<
|
|
|
+ string,
|
|
|
+ { icon: string; title: string; description: string }
|
|
|
+> = {
|
|
|
+ TODO: {
|
|
|
+ icon: "i-lucide-list-checks",
|
|
|
+ title: "No TODO tasks",
|
|
|
+ description: "Type TODO at the start of a line to create a task.",
|
|
|
+ },
|
|
|
+ DOING: {
|
|
|
+ icon: "i-lucide-loader",
|
|
|
+ title: "No in-progress tasks",
|
|
|
+ description: "Use DOING to mark a task as in progress.",
|
|
|
+ },
|
|
|
+ DONE: {
|
|
|
+ icon: "i-lucide-check-circle",
|
|
|
+ title: "No completed tasks",
|
|
|
+ description: "Completed tasks will appear here.",
|
|
|
+ },
|
|
|
+ LATER: {
|
|
|
+ icon: "i-lucide-clock",
|
|
|
+ title: "No deferred tasks",
|
|
|
+ description: "Use LATER to mark tasks you'll come back to.",
|
|
|
+ },
|
|
|
+ NOW: {
|
|
|
+ icon: "i-lucide-play",
|
|
|
+ title: "No active tasks",
|
|
|
+ description: "Use NOW to mark what you're currently working on.",
|
|
|
+ },
|
|
|
+ WAITING: {
|
|
|
+ icon: "i-lucide-hourglass",
|
|
|
+ title: "No waiting tasks",
|
|
|
+ description: "Use WAITING for tasks blocked on someone else.",
|
|
|
+ },
|
|
|
+ CANCELLED: {
|
|
|
+ icon: "i-lucide-x-circle",
|
|
|
+ title: "No cancelled tasks",
|
|
|
+ description: "Use CANCELLED to mark tasks that won't be done.",
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+const FALLBACK_EMPTY = {
|
|
|
+ icon: "i-lucide-search",
|
|
|
+ title: "No results",
|
|
|
+ description: "Try adjusting your query.",
|
|
|
+}
|
|
|
+
|
|
|
+export class QueryWidget {
|
|
|
+ dom: HTMLElement
|
|
|
+ private resultsContainer: HTMLElement
|
|
|
+ private _destroyed = false
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private attrs: QueryAttrs,
|
|
|
+ private graph: GraphService,
|
|
|
+ ) {
|
|
|
+ this.dom = document.createElement("span")
|
|
|
+ this.dom.className = "md-query-widget"
|
|
|
+
|
|
|
+ // Loading skeleton (mimics USkeleton's animate-pulse)
|
|
|
+ this.resultsContainer = document.createElement("div")
|
|
|
+ this.resultsContainer.className = "md-query-results"
|
|
|
+ this.resultsContainer.innerHTML = `
|
|
|
+ <div class="md-query-loading">
|
|
|
+ <div class="md-query-loading-item" style="width: 75%"></div>
|
|
|
+ <div class="md-query-loading-item" style="width: 50%"></div>
|
|
|
+ <div class="md-query-loading-item" style="width: 60%"></div>
|
|
|
+ </div>`
|
|
|
+ this.dom.appendChild(this.resultsContainer)
|
|
|
+
|
|
|
+ // Fire query asynchronously
|
|
|
+ this.executeQuery().then((results) => {
|
|
|
+ this.render(results)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeQuery(): Promise<QueryResult[]> {
|
|
|
+ try {
|
|
|
+ const { type, tag, property, equals } = this.attrs
|
|
|
+
|
|
|
+ if (tag) {
|
|
|
+ const incoming = await this.graph.getIncoming(tag)
|
|
|
+ return incoming
|
|
|
+ .filter((r) => r.via.type === "tagged")
|
|
|
+ .map((r) => ({
|
|
|
+ blockId: r.from.id,
|
|
|
+ pagePath: r.from.path ?? "",
|
|
|
+ title: r.from.title ?? "",
|
|
|
+ }))
|
|
|
+ }
|
|
|
+
|
|
|
+ if (property) {
|
|
|
+ const entities = await this.graph.queryByProperty(property, equals)
|
|
|
+ return entities.map((e) => ({
|
|
|
+ blockId: e.id,
|
|
|
+ pagePath: e.path ?? "",
|
|
|
+ title: e.title ?? "",
|
|
|
+ }))
|
|
|
+ }
|
|
|
+
|
|
|
+ if (type) {
|
|
|
+ const entities = await this.graph.queryByProperty("task-state", type)
|
|
|
+ return entities.map((e) => ({
|
|
|
+ blockId: e.id,
|
|
|
+ pagePath: e.path ?? "",
|
|
|
+ title: e.title ?? "",
|
|
|
+ }))
|
|
|
+ }
|
|
|
+
|
|
|
+ return []
|
|
|
+ } catch {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private render(results: QueryResult[]) {
|
|
|
+ if (this.isDestroyed()) return
|
|
|
+ if (results.length > 0) {
|
|
|
+ const list = document.createElement("div")
|
|
|
+ list.className = "md-query-list"
|
|
|
+
|
|
|
+ for (const r of results) {
|
|
|
+ const item = document.createElement("a")
|
|
|
+ item.className = "md-query-item"
|
|
|
+ item.href = `/${r.pagePath}`
|
|
|
+ item.setAttribute("data-block-id", r.blockId)
|
|
|
+ item.textContent = r.title || "(empty block)"
|
|
|
+ item.addEventListener("mousedown", (e) => {
|
|
|
+ // Prevent ProseMirror from intercepting the click and focusing the block
|
|
|
+ e.preventDefault()
|
|
|
+ e.stopPropagation()
|
|
|
+ })
|
|
|
+ item.addEventListener("click", (e) => {
|
|
|
+ e.preventDefault()
|
|
|
+ e.stopPropagation()
|
|
|
+ window.location.href = `/${r.pagePath}`
|
|
|
+ })
|
|
|
+ list.appendChild(item)
|
|
|
+ }
|
|
|
+
|
|
|
+ this.resultsContainer.innerHTML = ""
|
|
|
+ this.resultsContainer.appendChild(list)
|
|
|
+ } else {
|
|
|
+ const msg = EMPTY_MESSAGES[this.attrs.type ?? ""] ?? FALLBACK_EMPTY
|
|
|
+ this.resultsContainer.innerHTML = `
|
|
|
+ <div class="md-query-empty">
|
|
|
+ <span class="md-query-empty-icon ${msg.icon}"></span>
|
|
|
+ <p class="md-query-empty-title">${msg.title}</p>
|
|
|
+ <p class="md-query-empty-description">${msg.description}</p>
|
|
|
+ </div>`
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ destroy() {
|
|
|
+ this._destroyed = true
|
|
|
+ }
|
|
|
+
|
|
|
+ private isDestroyed(): boolean {
|
|
|
+ return this._destroyed || !this.dom.isConnected
|
|
|
+ }
|
|
|
+}
|