diff --git a/package.json b/package.json index 9f4537e07..e0f072e5f 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,12 @@ "author": "Michael Brenan", "license": "MIT", "devDependencies": { + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/commands": "^6.8.1", "@codemirror/language": "https://github.com/lishid/cm-language", + "@codemirror/search": "^6.5.10", "@codemirror/state": "^6.0.1", - "@codemirror/view": "^6.0.1", + "@codemirror/view": "^6.36.7", "@microsoft/api-extractor": "^7.52.7", "@types/jest": "^27.0.1", "@types/luxon": "^2.3.2", @@ -45,17 +48,19 @@ "typescript": "^5.4.2" }, "dependencies": { + "@codemirror/lang-javascript": "^6.2.3", "@datastructures-js/queue": "^4.2.3", "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@replit/codemirror-vim": "^6.3.0", "emoji-regex": "^10.2.1", "flatqueue": "^2.0.3", "localforage": "1.10.0", "luxon": "^2.4.0", "parsimmon": "^1.18.0", - "preact": "^10.17.1", - "react-select": "^5.8.0", + "preact": "^10.26.6", + "react-select": "^5.10.1", "sorted-btree": "^1.8.1", "sucrase": "3.35.0", "yaml": "^2.3.3" diff --git a/src/api/local-api.tsx b/src/api/local-api.tsx index 905c51048..4cefcf75d 100644 --- a/src/api/local-api.tsx +++ b/src/api/local-api.tsx @@ -7,27 +7,41 @@ import { Datacore } from "index/datacore"; import { SearchResult } from "index/datastore"; import { IndexQuery } from "index/types/index-query"; import { Indexable } from "index/types/indexable"; -import { MarkdownPage } from "index/types/markdown"; +import { MarkdownPage, MarkdownTaskItem } from "index/types/markdown"; import { App } from "obsidian"; -import { useFileMetadata, useFullQuery, useIndexUpdates, useInterning, useQuery } from "ui/hooks"; +import { useAsync, useFileMetadata, useFullQuery, useIndexUpdates, useInterning, useQuery } from "ui/hooks"; import * as luxon from "luxon"; import * as preact from "preact"; import * as hooks from "preact/hooks"; import { Result } from "./result"; import { Group, Stack } from "./ui/layout"; import { Embed, LineSpanEmbed } from "api/ui/embed"; -import { CURRENT_FILE_CONTEXT, ErrorMessage, Lit, Markdown, ObsidianLink } from "ui/markdown"; -import { CSSProperties } from "preact/compat"; +import { APP_CONTEXT, COMPONENT_CONTEXT, CURRENT_FILE_CONTEXT, DATACORE_CONTEXT, ErrorMessage, Lit, Markdown, ObsidianLink, SETTINGS_CONTEXT } from "ui/markdown"; +import { CSSProperties, Suspense } from "preact/compat"; import { Literal, Literals } from "expression/literal"; import { Button, Checkbox, Icon, Slider, Switch, Textbox, VanillaSelect } from "./ui/basics"; import { TableView } from "./ui/views/table"; import { Callout } from "./ui/views/callout"; +import { TaskList } from "./ui/views/task"; import { DataArray } from "./data-array"; import { Coerce } from "./coerce"; import { ScriptCache } from "./script-cache"; import { Expression } from "expression/expression"; import { Card } from "./ui/views/cards"; import { ListView } from "./ui/views/list"; +import { Modal, Modals, SubmittableModal, useModalContext } from "./ui/views/modal"; +import * as obsidian from "obsidian"; +import { ControlledEditable } from "ui/fields/editable"; +import { setTaskText, useSetField } from "utils/fields"; +import { completeTask } from "utils/task"; +import { + ControlledEditableTextField, + EditableTextField, + FieldCheckbox, + FieldSelect, + FieldSlider, + FieldSwitch, +} from "ui/fields/editable-fields"; /** * Local API provided to specific codeblocks when they are executing. @@ -37,6 +51,8 @@ export class DatacoreLocalApi { /** @internal The cache of all currently loaded scripts in this context. */ private scriptCache: ScriptCache; + private modalTypes: Modals = new Modals(); + public constructor(public api: DatacoreApi, public path: string) { this.scriptCache = new ScriptCache(this.core.datastore); } @@ -93,6 +109,9 @@ export class DatacoreLocalApi { * ``` */ public async require(path: string | Link): Promise { + if (typeof path === "string" && path === "obsidian") { + return Result.success(obsidian); + } const result = await this.scriptCache.load(path, { dc: this }); return result.orElseThrow(); } @@ -189,6 +208,35 @@ export class DatacoreLocalApi { public tryFullQuery(query: string | IndexQuery): Result, string>; public tryFullQuery(query: string | IndexQuery): Result, string> { return this.api.tryFullQuery(query); + } + /** Sets the text of a given task programmatically. */ + public setTaskText(newText: string, task: MarkdownTaskItem, newFields: Record = {}): void { + setTaskText(this.app, this.core, newText, task, newFields); + } + + /** Sets the completion status of a given task programmatically. */ + public setTaskCompletion(completed: boolean, task: MarkdownTaskItem): void { + completeTask(completed, task, this.app.vault, this.core); + } + + ////////////// + // Contexts // + ////////////// + + // export the necessary contexts to enable rendering + // datacore components outside the datacore plugin + // itself + get SETTINGS_CONTEXT(): typeof SETTINGS_CONTEXT { + return SETTINGS_CONTEXT; + } + get COMPONENT_CONTEXT(): typeof COMPONENT_CONTEXT { + return COMPONENT_CONTEXT; + } + get DATACORE_CONTEXT(): typeof DATACORE_CONTEXT { + return DATACORE_CONTEXT; + } + get APP_CONTEXT(): typeof APP_CONTEXT { + return APP_CONTEXT; } ///////////// @@ -219,6 +267,8 @@ export class DatacoreLocalApi { * React's reference-equality-based caching. */ public useInterning = useInterning; + public useAsync = useAsync; + public useSetField = useSetField; /** Memoize the input automatically and process it using a DataArray; returns a vanilla array back. */ public useArray( @@ -279,6 +329,8 @@ export class DatacoreLocalApi { /** Horizontal flexbox container; good for putting items together in a row. */ public Group = Group; + public Suspense = Suspense; + /** Renders a literal value in a pretty way that respects settings. */ public Literal = (({ value, sourcePath, inline }: { value: Literal; sourcePath?: string; inline?: boolean }) => { const implicitSourcePath = hooks.useContext(CURRENT_FILE_CONTEXT); @@ -389,6 +441,19 @@ export class DatacoreLocalApi { return ; }).bind(this); + /** Accessor for raw modal classes. */ + public get modals() { + return this.modalTypes; + } + + /** Wrapper around an obsidian modal. */ + public Modal = Modal; + + /** Wrapper around an obsidian modal that returns a result when submitted. */ + public SubmittableModal = SubmittableModal; + + public useModalContext = useModalContext; + /////////// // Views // /////////// @@ -402,11 +467,13 @@ export class DatacoreLocalApi { public List = ListView; /** A single card which can be composed into a grid view. */ public Card = Card; + public TaskList = TaskList; ///////////////////////// // Interative elements // ///////////////////////// + public ControlledEditable = ControlledEditable; public Button = Button; public Textbox = Textbox; public Callout = Callout; @@ -414,4 +481,26 @@ export class DatacoreLocalApi { public Slider = Slider; public Switch = Switch; public VanillaSelect = VanillaSelect; + public VanillaTextBox = ControlledEditableTextField; + + //////////////////////////////////// + // Stateful / internal components // + //////////////////////////////////// + + /** + * Updates the path for the local API; usually only called by the top-level script renderer on + * path changes (such as renaming a file). + * @internal + */ + updatePath(path: string): void { + this.path = path; + } + ///////////////////////// + // field editors // + ///////////////////////// + public FieldCheckbox = FieldCheckbox; + public FieldSlider = FieldSlider; + public FieldSelect = FieldSelect; + public FieldSwitch = FieldSwitch; + public TextField = EditableTextField; } diff --git a/src/api/ui/views/callout.tsx b/src/api/ui/views/callout.tsx index dd746597e..2732c3437 100644 --- a/src/api/ui/views/callout.tsx +++ b/src/api/ui/views/callout.tsx @@ -53,7 +53,7 @@ export function Callout({ data-callout-metadata={type?.split(METADATA_SPLIT_REGEX)?.[1]} data-callout={type?.split(METADATA_SPLIT_REGEX)?.[0]} data-callout-fold={open ? "+" : "-"} - className={combineClasses("datacore", "callout", collapsible ? "is-collapsible" : undefined)} + className={combineClasses("datacore", "callout", collapsible ? "is-collapsible" : undefined, !open ? "is-collapsed" : undefined)} >
collapsible && setOpen(!open)}> {icon &&
{icon}
} diff --git a/src/api/ui/views/list.tsx b/src/api/ui/views/list.tsx index a94a5eb6e..fc2d98aeb 100644 --- a/src/api/ui/views/list.tsx +++ b/src/api/ui/views/list.tsx @@ -10,6 +10,8 @@ import { ControlledPager, useDatacorePaging } from "./paging"; import { useAsElement } from "ui/hooks"; import { CSSProperties, ReactNode } from "preact/compat"; import { MarkdownListItem } from "index/types/markdown"; +import { BaseFieldProps } from "ui/fields/common-props"; +import { ControlledEditable, EditableElement } from "ui/fields/editable"; /** The render type of the list view. */ export type ListViewType = "ordered" | "unordered" | "block"; @@ -59,6 +61,8 @@ export interface ListViewProps { * If null, child extraction is disabled and no children will be fetched. If undefined, uses the default. */ childSource?: null | string | string[] | ((row: T) => T[]); + /** fields to display under each item in this task list */ + displayedFields?: (BaseFieldProps & { key: string })[]; } /** @@ -430,3 +434,27 @@ function fetchProps(element: T, props: string[]): T[] { return result; } +export function EditableListElement({ + element: item, + editor, + onUpdate, + file, + editorProps, +}: { + editor: (value: T) => EditableElement; + element: T; + file: string; + onUpdate: (value: T) => unknown; + editorProps: unknown; +}) { + return ( + + props={editorProps} + sourcePath={file} + content={item} + editor={editor(item)} + onUpdate={onUpdate} + defaultRender={} + /> + ); +} diff --git a/src/api/ui/views/lists.css b/src/api/ui/views/lists.css new file mode 100644 index 000000000..f42a917b0 --- /dev/null +++ b/src/api/ui/views/lists.css @@ -0,0 +1,54 @@ +.datacore-list-item-content { + display: inline-flex; + justify-content: space-between; + width: 100%; +} +.datacore-list-item-content > :first-child { + flex-grow: 1; +} + +:is(ul, ol) li:not(:first-of-type) p:first-of-type { + margin-block-start: unset !important; +} +ul.datacore.contains-task-list > li { + /* margin-inline-start: 0; */ +} +input.datacore.task-list-item-checkbox { + /* position: absolute; */ + float: left; + margin-inline-start: calc(var(--checkbox-size) * 0.1) !important; + /* margin-inline-start: 0 !important; */ +} + +li.datacore.task-list-item > *:nth-child(3) { + display: flow-root !important; + top: -5px; + padding-left: 0.5em; + position: relative; +} + +.datacore-collapser, +.datacore-collapser svg.svg-icon { + transition: transform 100ms ease-in-out; +} +.datacore-collapser.is-collapsed svg.svg-icon { + transform: rotate(calc(var(--direction) * -1 * 90deg)); +} +li.datacore.datacore.task-list-item .datacore-collapser { + margin-right: 0.7em; + float: left; +} +/* li.datacore.task-list-item > :first-child { + display: flex; + float: left; +} */ +li.datacore.task-list-item .datacore-collapser { + vertical-align: middle; + align-self: start; + top: -0.1em; + position: absolute; + margin-inline-start: calc(var(--checkbox-size) * -1.4); +} +li.datacore.task-list-item .datacore-collapser.no-children { + visibility: hidden; +} diff --git a/src/api/ui/views/modal.tsx b/src/api/ui/views/modal.tsx new file mode 100644 index 000000000..0f6ff2900 --- /dev/null +++ b/src/api/ui/views/modal.tsx @@ -0,0 +1,126 @@ +import { + ReactNode, + RefObject, + forwardRef, + useContext, + createContext, + memo, + Context as ReactContext, + useEffect, + useRef, + createPortal, + ComponentProps, + ForwardedRef, + useImperativeHandle, + useMemo, +} from "preact/compat"; +import { App, Modal as ObsidianModal } from "obsidian"; +import { APP_CONTEXT } from "ui/markdown"; +import { Literal } from "expression/literal"; +import { VNode } from "preact"; + +class DatacoreModal extends ObsidianModal { + constructor(app: App, public openCallback?: ObsidianModal["onOpen"], public onCancel?: ObsidianModal["onClose"]) { + super(app); + } + public onOpen() { + super.onOpen(); + this.openCallback?.(); + } + public onClose() { + super.onClose(); + this.onCancel?.(); + } +} + +class SubmittableDatacoreModal extends DatacoreModal { + constructor( + app: App, + public submitCallback?: (result: T) => void | Promise, + public openCallback?: ObsidianModal["onOpen"], + public onCancel?: ObsidianModal["onClose"] + ) { + super(app, openCallback, onCancel); + } + public onSubmit(result: T) { + this.close(); + this.submitCallback?.(result); + } +} + +export class Modals { + get submittableModal() { + return SubmittableDatacoreModal; + } + get modal() { + return DatacoreModal; + } +} + +interface ModalContextType { + modal: M; +} + +interface BaseModalProps { + title?: Literal | VNode | ReactNode; + children: ReactNode; + onCancel?: ObsidianModal["onClose"]; + onOpen?: ObsidianModal["onOpen"]; +} + +const MODAL_CONTEXT = createContext | null>(null); + +function ModalContext({ modal, children }: { modal: M; children: ReactNode }) { + const Ctx = MODAL_CONTEXT as ReactContext>; + return {children}; +} + +function useReusableImperativeHandle(modal: M, ref: ForwardedRef) { + useImperativeHandle(ref, () => modal, [modal]); +} + +function InnerSubmittableModal( + { + children, + onSubmit, + onCancel, + onOpen, + title, + }: BaseModalProps & { + onSubmit?: (result: T) => void | Promise; + }, + ref: ForwardedRef> +) { + const app = useContext(APP_CONTEXT)!; + const modal = useMemo( + () => new SubmittableDatacoreModal(app, onSubmit, onOpen, onCancel), + [app, onSubmit, onOpen, onCancel] + ); + useReusableImperativeHandle(modal, ref); + return ( + + {createPortal(<>{title}, modal.titleEl)} + {createPortal(<>{children}, modal.contentEl)} + + ); +} + +function InnerModal({ children, onCancel, onOpen, title }: BaseModalProps, ref: ForwardedRef) { + const app = useContext(APP_CONTEXT)!; + const modal = useMemo(() => new DatacoreModal(app, onOpen, onCancel), [app, onOpen, onCancel]); + useReusableImperativeHandle(modal, ref); + return ( + + {createPortal(<>{title}, modal.titleEl)} + {createPortal(<>{children}, modal.contentEl)} + + ); +} + +export function useModalContext() { + return useContext(MODAL_CONTEXT) as ModalContextType; +} + +export const SubmittableModal = forwardRef(InnerSubmittableModal) as typeof InnerSubmittableModal; + +export const Modal = forwardRef(InnerModal) as typeof InnerModal; diff --git a/src/api/ui/views/table-dispatch.ts b/src/api/ui/views/table-dispatch.ts new file mode 100644 index 000000000..7893a2372 --- /dev/null +++ b/src/api/ui/views/table-dispatch.ts @@ -0,0 +1,56 @@ +import { createContext } from "preact"; +import { Dispatch, useMemo, useReducer, Reducer, useContext } from "preact/hooks"; + +/** The ways that the table can be sorted. */ +export type SortDirection = "ascending" | "descending"; +export type SortOn = { type: "column"; id: string; direction: SortDirection }; + +export type TableAction = { type: "sort-column"; column: string; direction: SortDirection | undefined }; + +export interface TableState { + /** mapping of column ids to sort directions */ + sorts: Record; +} + +export function tableReducer(state: TableState, action: TableAction): TableState { + switch (action.type) { + case "sort-column": { + const newSorts = {...state.sorts}; + if (action.direction === undefined) { + delete newSorts[action.column]; + } else { + newSorts[action.column] = action.direction; + } + return { + ...state, + sorts: newSorts, + }; + } + } + console.warn("datacore: Encountered unrecognized operation: " + (action as TableAction).type); + return state; +} + +export function useTableDispatch(initial: TableState | (() => TableState)): [TableState, Dispatch] { + const init = useMemo(() => (typeof initial == "function" ? initial() : initial), []); + return useReducer(tableReducer as Reducer, init); +} + +export type CommonTableContext = TableState & { + dispatch: Dispatch +} + +export type TableContext = CommonTableContext & { +} + +export const TABLE_CONTEXT = createContext(null); + +export const COMMON_TABLE_CONTEXT = createContext(null); + +export function useTableContext() { + return useContext(TABLE_CONTEXT); +} + +export function useCommonTableContext() { + return useContext(COMMON_TABLE_CONTEXT); +} diff --git a/src/api/ui/views/table.css b/src/api/ui/views/table.css index 76c1e50be..58a8c5cb4 100644 --- a/src/api/ui/views/table.css +++ b/src/api/ui/views/table.css @@ -31,7 +31,7 @@ max-width: 100%; } -.datacore-table ul, +.datacore-table ul:not(.contains-task-list), .datacore-table ol { margin-block-start: 0.2em !important; margin-block-end: 0.2em !important; @@ -53,3 +53,19 @@ align-items: center; flex-grow: 1; } + +.datacore-table td .datacore-collapser { + max-width: 1.25em; + max-height: min-content; + vertical-align: middle; + display: flex; +} +.datacore-table td:has(.datacore-card-collapser) { + max-width: 1.25em; +} + +.datacore-table td .datacore-editable-outer, +.datacore-table td .datacore-editable { + width: 100%; + display: inline-block; +} diff --git a/src/api/ui/views/table.tsx b/src/api/ui/views/table.tsx index 2520aa55e..3a832fa6b 100644 --- a/src/api/ui/views/table.tsx +++ b/src/api/ui/views/table.tsx @@ -4,13 +4,19 @@ import { GroupElement, Grouping, Groupings, Literal, Literals } from "expression/literal"; import { useContext, useMemo, useRef } from "preact/hooks"; import { CURRENT_FILE_CONTEXT, Lit } from "ui/markdown"; -import { useAsElement, useInterning } from "ui/hooks"; +import { useAsElement, useInterning, useStableCallback } from "ui/hooks"; import { Fragment } from "preact/jsx-runtime"; -import { ReactNode } from "preact/compat"; +import { faSortDown, faSortUp, faSort } from "@fortawesome/free-solid-svg-icons"; +import {useTableDispatch, type SortDirection, type SortOn, TABLE_CONTEXT, TableContext, useTableContext, CommonTableContext} from "./table-dispatch"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { PropsWithChildren, ReactNode } from "preact/compat"; import { ControlledPager, useDatacorePaging } from "./paging"; import "./table.css"; +import { EditableElement, useEditableDispatch } from "ui/fields/editable"; + /** * A simple column definition which allows for custom renderers and titles. @@ -32,7 +38,25 @@ export interface TableColumn { value: (object: T) => V; /** Called to render the given column value. Can depend on both the specific value and the row object. */ - render?: (value: V, object: T) => Literal | ReactNode; + render?: (value: V, object: T) => Literal | ReactNode; + + /** whether or not this column can be sorted on. */ + sortable?: boolean; + + /** comparator used when sorting this column. */ + comparator?: (first: V, second: V, firstObject: T, secondObject: T) => number; + + /** whether this column is editable or not */ + editable?: boolean; + + /** Rendered when editing the column */ + editor?: EditableElement; + + /** Props to pass to the editor component (if any) */ + editorProps: unknown; + + /** Called when the column value updates. */ + onUpdate?: (value: V, object: T) => unknown; } /** @@ -69,6 +93,9 @@ export interface TableViewProps { * If a number, will scroll only if the number is greater than the current page size. **/ scrollOnPaging?: boolean | number; + + /** The fields to sort the view on, if relevant. */ + sortOn?: SortOn[]; } /** @@ -94,12 +121,36 @@ export function TableView(props: TableViewProps) { elements: totalElements, container: tableRef, }); + // Cache sorts by value equality and filter to only sortable valid fields. + const rawSorts = useInterning(props.sortOn, (a, b) => Literals.compare(a, b) == 0); + const [tableState, dispatch] = useTableDispatch(() => ({ + sorts: Object.fromEntries((rawSorts?.filter((sort) => { + const column = columns.find((col) => col.id == sort.id); + return column && (column.sortable ?? true); + }) ?? []).map(a => [a.id, a.direction])) + })) + const idsToColumns = useMemo(() => { + return Object.fromEntries(columns.map(col => [col.id, col])); + }, [columns]); + const sortedRows = useMemo(() => { + if (tableState.sorts == undefined || Object.keys(tableState.sorts).length == 0) return props.rows; + + const comparators = Object.entries(tableState.sorts).map(([id, direction]) => { + const col = idsToColumns[id]; + const comp = col.comparator ? ((a: T, b: T) => col.comparator!(col?.value(a), col?.value(b), a, b)) : (a: T, b: T) => DEFAULT_TABLE_COMPARATOR(col.value(a), col.value(b), a, b); + return { + fn: comp, + direction: direction, + }; + }); + return Groupings.sort(props.rows, comparators); + }, [props.rows, tableState.sorts, columns]); const pagedRows = useMemo(() => { if (paging.enabled) - return Groupings.slice(props.rows, paging.page * paging.pageSize, (paging.page + 1) * paging.pageSize); - else return props.rows; - }, [paging.page, paging.pageSize, paging.enabled, props.rows]); + return Groupings.slice(sortedRows, paging.page * paging.pageSize, (paging.page + 1) * paging.pageSize); + else return sortedRows; + }, [paging.page, paging.pageSize, paging.enabled, sortedRows]); const groupings = useMemo(() => { if (!props.groupings) return undefined; @@ -110,6 +161,7 @@ export function TableView(props: TableViewProps) { }, [props.groupings]); return ( +
@@ -129,6 +181,7 @@ export function TableView(props: TableViewProps) { )} + ); } @@ -155,7 +208,10 @@ export function TableViewHeaderCell({ column }: { column: TableColumn }) { // We use an internal div to avoid flex messing with the table layout. return ( ); } @@ -231,7 +287,11 @@ export function TableGroupHeader({ */ export function TableRow({ level, row, columns }: { level: number; row: T; columns: TableColumn[] }) { return ( - + {columns.map((col) => ( ))} @@ -244,12 +304,73 @@ export function TableRow({ level, row, columns }: { level: number; row: T; co * @hidden */ export function TableRowCell({ row, column }: { row: T; column: TableColumn }) { - const value = useMemo(() => column.value(row), [row, column.value]); + const value = column.value(row); + const [editableState, dispatch] = useEditableDispatch({ + content: value, + isEditing: false, + updater: (v) => column.onUpdate && column.onUpdate(v, row), + }); const renderable = useMemo(() => { - if (column.render) return column.render(value, row); - else return value; - }, [row, column.render, value]); + if (column.render) { + let r = column.render(value, row); + return r; + } else return value; + }, [row, column.render, editableState.content, value]); + const rendered = useAsElement(renderable); - return ; + const { editor: Editor } = column; + return ( + + ); +} + +export function SortButton({ + columnId, + className, + contextGetter = useTableContext, +}: { + className?: string; + columnId: string; + contextGetter?: () => CommonTableContext | null; +}) { + const {dispatch, ...state} = contextGetter()!; + const direction = state.sorts[columnId]; + const icon = useMemo(() => { + if (direction == "ascending") return faSortDown; + else if (direction == "descending") return faSortUp; + return faSort; + }, [direction]); + const onClick = useStableCallback(() => { + dispatch({type: "sort-column", column: columnId, direction: direction == "ascending" ? "descending" : "ascending"}); + }, [columnId, dispatch, state.sorts]); + + return ( +
+ +
+ ); +} + +/** Default comparator for sorting on a table column. */ +export const DEFAULT_TABLE_COMPARATOR: (a: Literal, b: Literal, ao: T, bo: T) => number = (a, b, _ao, _bo) => + Literals.compare(a, b); +/** + * @hidden + * @group Components + */ +export function TableContextProvider({dispatch, children, ...rest}: PropsWithChildren) { + return + {children} + } + diff --git a/src/api/ui/views/task.tsx b/src/api/ui/views/task.tsx new file mode 100644 index 000000000..2e7544fd7 --- /dev/null +++ b/src/api/ui/views/task.tsx @@ -0,0 +1,327 @@ +/** + * @module views + */ + +import { MarkdownListItem, MarkdownTaskItem } from "index/types/markdown"; +import { EditableListElement, ListViewProps } from "api/ui/views/list"; +import { useStableCallback } from "ui/hooks"; +import { Fragment, RefObject } from "preact"; +import { APP_CONTEXT, DATACORE_CONTEXT } from "ui/markdown"; +import { JSXInternal } from "preact/src/jsx"; +import { Dispatch, useContext, useMemo, useRef, useState } from "preact/hooks"; +import { completeTask, rewriteTask } from "utils/task"; +import { Literal, Literals } from "expression/literal"; +import { + EditableAction, + EditableListField, + editableReducer, + EditableState, + TextEditable, + useEditableDispatch, +} from "ui/fields/editable"; +import { setInlineField } from "index/import/inline-field"; +import { Field } from "expression/field"; +import { DateTime } from "luxon"; +import "./lists.css"; + +/** + * Props passed to the task list component. + * @group Props + */ +export interface TaskProps extends ListViewProps { + /** task states to cycle through, if specified */ + additionalStates?: string[]; +} + +/** + * Represents a list of tasks. + * @param props + * @group Components + */ +export function TaskList({ + rows: items, + additionalStates: states, + renderer: listRenderer = (item) => ( + + onUpdate={useListItemEditing(item, "")} + element={item.$cleantext!} + file={item.$file} + editorProps={{ markdown: true, sourcePath: item.$file }} + editor={(it) => TextEditable} + /> + ), + ...rest +}: TaskProps) { + const content = useMemo(() => { + return ( +
    + {items?.map((item) => + item instanceof MarkdownTaskItem ? ( + + ) : ( +
  • + {listRenderer(item as MarkdownListItem | MarkdownTaskItem)} +
    + +
    +
  • + ) + )} +
+ ); + }, [items, states]); + return {!!items && content}; +} +/** + * Represents a single item in a task listing. + * @param props - the component's props + * @param props.item - the current task being rendered + * @param props.state - the {@link TaskProps} of the {@link TaskList} in which this Task appears + * @group Components + */ +export function Task({ item, state: props }: { item: MarkdownTaskItem; state: TaskProps }) { + const app = useContext(APP_CONTEXT); + const core = useContext(DATACORE_CONTEXT); + const { settings } = core; + const states = [" ", ...(props.additionalStates || []), "x"]; + const nextState = () => { + if (props.additionalStates && props.additionalStates?.length > 0) { + let curIndex = states.findIndex((a) => a === item.$status); + curIndex++; + if (curIndex >= states.length) { + curIndex = 0; + } + return states[curIndex]; + } else { + return item.$completed ? " " : "x"; + } + }; + const [status, setStatus] = useState(item.$status); + const completedRef = useRef>>(null); + const onChecked = useStableCallback(async (evt: JSXInternal.TargetedMouseEvent) => { + const completed = evt.currentTarget.checked; + let newStatus: string; + if (evt.shiftKey) { + newStatus = nextState(); + } else { + newStatus = completed ? "x" : " "; + } + setStatus(newStatus); + await completeTask(completed, item, app.vault, core); + const nv = completed ? DateTime.now().toFormat(settings.defaultDateFormat) : null; + completedRef.current && completedRef.current({ type: "commit", newValue: nv }); + }, []); + + const checked = useMemo(() => status !== " ", [item.$status, item, status]); + const updater = useListItemEditing(item, status); + const eState: EditableState = useMemo(() => { + return { + updater, + content: item.$cleantext, + inline: false, + isEditing: false, + } as EditableState; + }, [item.$cleantext, item.$text, updater]); + const theElement = useMemo( + () => , + [eState.content, item, props.rows] + ); + + const [collapsed, setCollapsed] = useState(true); + const hasChildren = useMemo(() => item.$elements.length > 0, [item, item.$elements, item.$elements.length]); + + return ( +
  • + setCollapsed((c) => !c)} + collapsed={collapsed} + hasChildren={hasChildren} + /> + console.log(e.currentTarget.value)} + /> +
    +
    + {theElement} +
    + +
    +
    +
    + {hasChildren && !collapsed && } +
  • + ); +} + +function CollapseIndicator({ + collapsed, + onClick, + hasChildren, +}: { + collapsed: boolean; + onClick: () => void; + hasChildren: boolean; +}) { + const toggleCnames = ["datacore-collapser"]; + if (collapsed) toggleCnames.push("is-collapsed"); + if (!hasChildren) toggleCnames.push("no-children"); + return ( +
    + + + +
    + ); +} + +/** + * Displays an editable set of fields below a task or list item. + * @hidden + * @group Components + */ +export function ListItemFields({ + displayedFields: displayedFieldsProp, + item, + completedRef, +}: { + displayedFields?: TaskProps["displayedFields"]; + item: MarkdownTaskItem | MarkdownListItem; + completedRef?: RefObject> | null>; +}) { + const displayedFields = useMemo(() => { + if (displayedFieldsProp != undefined) return displayedFieldsProp; + else { + return Object.values(item.$infields).map((f) => { + return { + key: f.key, + type: Literals.typeOf(f.value), + config: {}, + editable: true, + renderAs: "raw", + } as NonNullable[0]; + }); + } + }, [displayedFieldsProp, item.$infields, item]); + return ( + <> + {displayedFields.map((ifield) => ( + + ))} + + ); +} + +/** + * Renders a single field for a list item. + * @hidden + * @group Components + */ +export function ListItemField({ + ifield, + item, + completedRef, +}: { + ifield: NonNullable[number]; + item: MarkdownTaskItem | MarkdownListItem; + completedRef?: RefObject> | null>; +}) { + const app = useContext(APP_CONTEXT); + const core = useContext(DATACORE_CONTEXT); + const { settings } = core; + ifield.key = ifield.key.toLocaleLowerCase(); + let defVal = typeof ifield.defaultValue == "function" ? ifield.defaultValue() : ifield.defaultValue; + let defField: Field = { + key: ifield.key, + value: defVal, + raw: Literals.toString(defVal), + } as any; + const fieldValue = item.$infields[ifield?.key]?.value || defField.value!; + const updater = useStableCallback( + (val: Literal) => { + const dateString = (v: Literal) => + v instanceof DateTime + ? v.toFormat(settings.defaultDateFormat) + : v != null + ? Literals.toString(v) + : undefined; + + let withFields = item.$text; + if (withFields && item.$text) { + if (item.$infields[ifield.key]) item.$infields[ifield.key].value = dateString(val)!; + for (let field in item.$infields) { + withFields = setInlineField(withFields, field, dateString(item.$infields[field]?.value)); + } + withFields = setInlineField(item.$text, ifield.key, dateString(val)); + rewriteTask(app.vault, core, item, item instanceof MarkdownTaskItem ? item.$status : " ", withFields); + } + }, + [item.$infields, item.$text, item.$infields, completedRef] + ); + let [state, dispatch] = useEditableDispatch(() => ({ + content: fieldValue, + isEditing: false, + updater, + })); + if (ifield.key == settings.taskCompletionText && completedRef) { + completedRef.current = dispatch; + } + state = editableReducer(state, { type: "content-changed", newValue: fieldValue }); + return ( + + ); +} + +function useListItemEditing(item: MarkdownTaskItem | MarkdownListItem, status: string) { + const app = useContext(APP_CONTEXT); + const core = useContext(DATACORE_CONTEXT); + return useStableCallback( + async (val: Literal) => { + if (typeof val === "string") { + let withFields = `${val}${Object.keys(item.$infields).length ? " " : ""}`; + for (let field in item.$infields) { + withFields = setInlineField(withFields, field, item.$infields[field].raw); + } + await rewriteTask(app.vault, core, item, status, withFields); + } + }, + [status, item] + ); +} diff --git a/src/expression/literal.ts b/src/expression/literal.ts index e1383fee7..924d24318 100644 --- a/src/expression/literal.ts +++ b/src/expression/literal.ts @@ -411,6 +411,37 @@ export type Grouping = T[] | GroupElement[]; * @hidden */ export namespace Groupings { + /** + * recursively sort a grouping + */ + export function sort(rows: Grouping, comparators: { + fn: (first: T, second: T) => number; + direction: "ascending" | "descending"; + }[]): Grouping { + const cmp = | T>(a: E, b: E): number => { + for(let comparator of comparators) { + let result: number = 0; + const direction = comparator.direction === "ascending" ? 1 : -1; + if(isElementGroup(a) && isElementGroup(b)) { + result = 0; + } else if(!Groupings.isElementGroup(a) && !Groupings.isElementGroup(b)) { + result = direction * comparator.fn(a as T, b as T); + } + if(result != 0) return result; + } + return 0; + } + if(isLeaf(rows)) { + return ([] as T[]).concat(rows).sort(cmp); + } + const sortMapper = (item: GroupElement | T): (GroupElement | T) => { + if(isElementGroup(item)) { + return {key: item.key, rows: ([] as any).concat(item.rows).sort(cmp).map(sortMapper)} + } + return item + } + return ([] as GroupElement[]).concat(rows).sort(cmp).map(sortMapper) as any; + } /** Determines if the given group entry is a standalone value, or a grouping of sub-entries. */ export function isElementGroup(entry: unknown): entry is GroupElement { return Literals.isObject(entry) && Object.keys(entry).length == 2 && "key" in entry && "rows" in entry; diff --git a/src/index/import/markdown.ts b/src/index/import/markdown.ts index 46aa56127..eac4fd281 100644 --- a/src/index/import/markdown.ts +++ b/src/index/import/markdown.ts @@ -32,7 +32,7 @@ const YAML_DATA_REGEX = /```yaml:data/i; /** Matches the start of any codeblock fence. */ const CODEBLOCK_FENCE_REGEX = /^(?:```|~~~)(.*)$/im; /** Matches list items (including inside text blocks). */ -const LIST_ITEM_REGEX = /^[\s>]*(\d+\.|\d+\)|\*|-|\+)\s*(\[.{0,1}\])?\s*(.*)$/mu; +const LIST_ITEM_REGEX = /^[\s>]*(\d+\.|\d+\)|\*|-|\+)\s*(\[.{0,1}\])?\s*(.*)/msu; /** * Given the raw source and Obsidian metadata for a given markdown file, @@ -161,7 +161,7 @@ export function markdownSourceImport( // All list items in lists. Start with a simple trivial pass. const listItems = new BTree(undefined, (a, b) => a - b); for (const list of metadata.listItems || []) { - const line = lines[list.position.start.line]; + const line = lines.slice(list.position.start.line, list.position.end.line + 1).join("\n"); // TODO: Implement flag which skips indexing list items. const match = line.match(LIST_ITEM_REGEX); diff --git a/src/main.ts b/src/main.ts index e3ab60e12..94ffd5258 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { Datacore } from "index/datacore"; import { DateTime } from "luxon"; import { App, Plugin, PluginSettingTab, Setting } from "obsidian"; import { DEFAULT_SETTINGS, Settings } from "settings"; +import { DatacoreQueryView as DatacoreJSView, VIEW_TYPE_DATACOREJS } from "ui/view-page"; /** @internal Reactive data engine for your Obsidian.md vault. */ export default class DatacorePlugin extends Plugin { @@ -55,6 +56,20 @@ export default class DatacorePlugin extends Plugin { callback: async () => { console.log("Datacore: dropping the datastore and reindexing all items."); await this.core.reindex(); + }, + }); + // Views: DatacoreJS view. + // @ts-ignore be quiet + this.registerView(VIEW_TYPE_DATACOREJS, (leaf) => new DatacoreJSView(leaf, this.api)); + + // Add a command for creating a new view page. + this.addCommand({ + id: "datacore-add-view-page", + name: "Create View Page", + callback: () => { + const newLeaf = this.app.workspace.getLeaf("tab"); + newLeaf.setViewState({ type: VIEW_TYPE_DATACOREJS, active: true }); + this.app.workspace.setActiveLeaf(newLeaf, { focus: true }); }, }); diff --git a/src/settings.ts b/src/settings.ts index 88df196ef..4c370debb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -36,6 +36,15 @@ export interface Settings { indexInlineFields: boolean; /** Whether to index list and task item text and states. Indexing lists & tasks requires some additional regex parsing which makes indexing modestly slower. */ indexListItems: boolean; + + /** Whether to check task items off recursively in datacore views */ + recursiveTaskCompletion: boolean; + + /** Name of the inline field in which to store completion date/time */ + taskCompletionText: string; + + /** If enabled, automatic completions will use emoji shorthand ✅ YYYY-MM-DD instead of [completion:: date]. */ + taskCompletionUseEmojiShorthand: boolean; } /** Default settings for the plugin. */ @@ -58,4 +67,7 @@ export const DEFAULT_SETTINGS: Readonly = Object.freeze({ indexInlineFields: true, indexListItems: true, + recursiveTaskCompletion: false, + taskCompletionText: "completedAt", + taskCompletionUseEmojiShorthand: false, }); diff --git a/src/typings/obsidian-ex.d.ts b/src/typings/obsidian-ex.d.ts index 478518b1f..33e47edc9 100644 --- a/src/typings/obsidian-ex.d.ts +++ b/src/typings/obsidian-ex.d.ts @@ -1,10 +1,49 @@ import type { DatacorePlugin } from "main"; -import type { CanvasMetadataIndex } from "index/types/json/canvas"; - +import { Extension } from "@codemirror/state"; +import type { DatacoreApi } from "api/api"; +import { CanvasMetadataIndex } from "index/types/json/canvas"; import "obsidian"; +import { App } from "obsidian"; +import * as hooks from "preact/hooks"; /** Provides extensions used by datacore or provider to other plugins via datacore. */ declare module "obsidian" { + interface WorkspaceLeaf { + serialize(): { + id: string; + type: "leaf"; + state: { + type: string; + state: any; + }; + }; + tabHeaderEl: HTMLElement; + tabHeaderInnerTitleEl: HTMLElement; + } + interface View { + getState(): any; + } + interface ItemView { + titleEl: HTMLElement; + getState(): any; + } + + interface InternalPlugin { + id: string; + name: string; + description: string; + instance: T; + } + export interface PagePreviewPlugin { + onLinkHover: ( + view: View, + hovered: HTMLElement, + hoveredPath: string, + sourcePath: string, + _unknown: unknown + ) => void; + } + interface FileManager { linkUpdaters?: { canvas?: { @@ -16,16 +55,26 @@ declare module "obsidian" { }; }; } - + interface Vault { + getConfig: (conf: string) => any; + } interface App { appId?: string; plugins: { enabledPlugins: Set; plugins: { - datacore?: DatacorePlugin; + datacore?: { + api: DatacoreApi; + }; + "datacore-addon-autocomplete"?: { + readonly extensions: Extension[]; + }; }; }; + internalPlugins: { + getPluginById: (id: string) => InternalPlugin; + }; embedRegistry: { embedByExtension: { @@ -54,5 +103,10 @@ declare module "obsidian" { declare global { interface Window { datacore?: DatacoreApi; + app: App; + CodeMirror: { + defineMode: (mode: string, conf: (config: any) => any) => unknown; + [key: string]: any; + }; } } diff --git a/src/typings/select.d.ts b/src/typings/select.d.ts new file mode 100644 index 000000000..568ce8166 --- /dev/null +++ b/src/typings/select.d.ts @@ -0,0 +1,20 @@ + +declare module "react-select" { + import { RefAttributes, ReactElement, JSX } from "preact/compat"; + import { StateManagerAdditionalProps } from "react-select/dist/declarations/src/useStateManager"; + import { Props } from "react-select/dist/declarations/src/Select"; + import Select from "react-select/dist/declarations/src/Select"; + export * from "react-select/dist/declarations/src/types"; + declare type StateManagedPropKeys = 'inputValue' | 'menuIsOpen' | 'onChange' | 'onInputChange' | 'onMenuClose' | 'onMenuOpen' | 'value'; + declare type PublicBaseSelectProps> = JSX.LibraryManagedAttributes>; + declare type SelectPropsWithOptionalStateManagedProps> = Omit, StateManagedPropKeys> & Partial>; + export declare type StateManagerProps
    -
    {header}
    +
    + {column.sortable && } +
    {header}
    +
    {rendered} dispatch({ type: "editing-toggled", newValue: !editableState.isEditing })} + className="datacore-table-cell" + > + {column.editable && editableState.isEditing && Editor ? ( + + ) : ( + rendered + )} +