diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 230c4783c..425742ee3 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -804,6 +804,16 @@ export default class FNote { return this.getAttributeValue(LABEL, name); } + getLabelOrRelation(nameWithPrefix: string) { + if (nameWithPrefix.startsWith("#")) { + return this.getLabelValue(nameWithPrefix.substring(1)); + } else if (nameWithPrefix.startsWith("~")) { + return this.getRelationValue(nameWithPrefix.substring(1)); + } else { + return this.getLabelValue(nameWithPrefix); + } + } + /** * @param name - relation name * @returns relation value if relation exists, null otherwise diff --git a/apps/client/src/services/attributes.ts b/apps/client/src/services/attributes.ts index 0b605f5bd..694eb1b02 100644 --- a/apps/client/src/services/attributes.ts +++ b/apps/client/src/services/attributes.ts @@ -22,6 +22,15 @@ export async function setLabel(noteId: string, name: string, value: string = "", }); } +export async function setRelation(noteId: string, name: string, value: string = "", isInheritable = false) { + await server.put(`notes/${noteId}/set-attribute`, { + type: "relation", + name: name, + value: value, + isInheritable + }); +} + async function removeAttributeById(noteId: string, attributeId: string) { await server.remove(`notes/${noteId}/attributes/${attributeId}`); } @@ -51,6 +60,23 @@ function removeOwnedLabelByName(note: FNote, labelName: string) { return false; } +/** + * Removes a relation identified by its name from the given note, if it exists. Note that the relation must be owned, i.e. + * it will not remove inherited attributes. + * + * @param note the note from which to remove the relation. + * @param relationName the name of the relation to remove. + * @returns `true` if an attribute was identified and removed, `false` otherwise. + */ +function removeOwnedRelationByName(note: FNote, relationName: string) { + const relation = note.getOwnedRelation(relationName); + if (relation) { + removeAttributeById(note.noteId, relation.attributeId); + return true; + } + return false; +} + /** * Sets the attribute of the given note to the provided value if its truthy, or removes the attribute if the value is falsy. * For an attribute with an empty value, pass an empty string instead. @@ -116,8 +142,10 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin export default { addLabel, setLabel, + setRelation, setAttribute, removeAttributeById, removeOwnedLabelByName, + removeOwnedRelationByName, isAffecting }; diff --git a/apps/client/src/services/load_results.ts b/apps/client/src/services/load_results.ts index 4a4875725..899560de4 100644 --- a/apps/client/src/services/load_results.ts +++ b/apps/client/src/services/load_results.ts @@ -1,11 +1,21 @@ -import type { AttachmentRow, EtapiTokenRow, OptionNames } from "@triliumnext/commons"; +import type { AttachmentRow, EtapiTokenRow, NoteType, OptionNames } from "@triliumnext/commons"; import type { AttributeType } from "../entities/fattribute.js"; import type { EntityChange } from "../server_types.js"; // TODO: Deduplicate with server. interface NoteRow { + blobId: string; + dateCreated: string; + dateModified: string; isDeleted?: boolean; + isProtected?: boolean; + mime: string; + noteId: string; + title: string; + type: NoteType; + utcDateCreated: string; + utcDateModified: string; } // TODO: Deduplicate with BranchRow from `rows.ts`/ diff --git a/apps/client/src/services/toast.ts b/apps/client/src/services/toast.ts index cc67fa225..5325a06bc 100644 --- a/apps/client/src/services/toast.ts +++ b/apps/client/src/services/toast.ts @@ -77,11 +77,11 @@ function closePersistent(id: string) { $(`#toast-${id}`).remove(); } -function showMessage(message: string, delay = 2000) { +function showMessage(message: string, delay = 2000, icon = "check") { console.debug(utils.now(), "message:", message); toast({ - icon: "check", + icon, message: message, autohide: true, delay diff --git a/apps/client/src/stylesheets/theme-next/shell.css b/apps/client/src/stylesheets/theme-next/shell.css index e84c4544d..2ae36db2a 100644 --- a/apps/client/src/stylesheets/theme-next/shell.css +++ b/apps/client/src/stylesheets/theme-next/shell.css @@ -16,6 +16,10 @@ background-color: var(--root-background); } +body.mobile #root-widget { + background-color: var(--main-background-color); +} + body { --native-titlebar-darwin-x-offset: 10; --native-titlebar-darwin-y-offset: 12 !important; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index ef9605d5e..54025d690 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2035,7 +2035,8 @@ "add-column": "Add Column", "add-column-placeholder": "Enter column name...", "edit-note-title": "Click to edit note title", - "edit-column-title": "Click to edit column title" + "edit-column-title": "Click to edit column title", + "column-already-exists": "This column already exists on the board." }, "presentation_view": { "edit-slide": "Edit this slide", diff --git a/apps/client/src/widgets/attribute_widgets/PromotedAttributesDisplay.css b/apps/client/src/widgets/attribute_widgets/UserAttributesList.css similarity index 72% rename from apps/client/src/widgets/attribute_widgets/PromotedAttributesDisplay.css rename to apps/client/src/widgets/attribute_widgets/UserAttributesList.css index 4bed347e4..ef8c1763d 100644 --- a/apps/client/src/widgets/attribute_widgets/PromotedAttributesDisplay.css +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.css @@ -1,4 +1,4 @@ -.promoted-attributes { +.user-attributes { display: flex; flex-wrap: wrap; gap: 8px; @@ -6,7 +6,7 @@ margin-top: 8px; } -.promoted-attributes .promoted-attribute { +.user-attributes .user-attribute { padding: 2px 10px; border-radius: 9999px; white-space: nowrap; @@ -17,15 +17,15 @@ line-height: 1.2; } -.promoted-attributes .promoted-attribute:hover { +.user-attributes .user-attribute:hover { background-color: var(--chip-bg-hover, rgba(0, 0, 0, 0.12)); border-color: var(--chip-border-hover, rgba(0, 0, 0, 0.22)); } -.promoted-attributes .promoted-attribute .name { +.user-attributes .user-attribute .name { font-weight: 600; } -.promoted-attributes .promoted-attribute .value { +.user-attributes .user-attribute .value { opacity: 0.9; } \ No newline at end of file diff --git a/apps/client/src/widgets/attribute_widgets/PromotedAttributesDisplay.tsx b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx similarity index 74% rename from apps/client/src/widgets/attribute_widgets/PromotedAttributesDisplay.tsx rename to apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx index 987647519..f01e70b49 100644 --- a/apps/client/src/widgets/attribute_widgets/PromotedAttributesDisplay.tsx +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx @@ -1,16 +1,16 @@ import { useState } from "preact/hooks"; import FNote from "../../entities/fnote"; -import "./PromotedAttributesDisplay.css"; +import "./UserAttributesList.css"; import { useTriliumEvent } from "../react/hooks"; import attributes from "../../services/attributes"; import { DefinitionObject } from "../../services/promoted_attribute_definition_parser"; import { formatDateTime } from "../../utils/formatters"; -import { ComponentChild, ComponentChildren, CSSProperties } from "preact"; +import { ComponentChildren, CSSProperties } from "preact"; import Icon from "../react/Icon"; import NoteLink from "../react/NoteLink"; import { getReadableTextColor } from "../../services/css_class_manager"; -interface PromotedAttributesDisplayProps { +interface UserAttributesListProps { note: FNote; ignoredAttributes?: string[]; } @@ -23,39 +23,39 @@ interface AttributeWithDefinitions { def: DefinitionObject; } -export default function PromotedAttributesDisplay({ note, ignoredAttributes }: PromotedAttributesDisplayProps) { - const promotedDefinitionAttributes = useNoteAttributesWithDefinitions(note, ignoredAttributes); - return promotedDefinitionAttributes?.length > 0 && ( -
- {promotedDefinitionAttributes?.map(attr => buildPromotedAttribute(attr))} +export default function UserAttributesDisplay({ note, ignoredAttributes }: UserAttributesListProps) { + const userAttributes = useNoteAttributesWithDefinitions(note, ignoredAttributes); + return userAttributes?.length > 0 && ( +
+ {userAttributes?.map(attr => buildUserAttribute(attr))}
) } function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] { - const [ promotedDefinitionAttributes, setPromotedDefinitionAttributes ] = useState(getAttributesWithDefinitions(note, attributesToIgnore)); + const [ userAttributes, setUserAttributes ] = useState(getAttributesWithDefinitions(note, attributesToIgnore)); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) { - setPromotedDefinitionAttributes(getAttributesWithDefinitions(note, attributesToIgnore)); + setUserAttributes(getAttributesWithDefinitions(note, attributesToIgnore)); } }); - return promotedDefinitionAttributes; + return userAttributes; } -function PromotedAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) { +function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) { const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`; return ( - + {children} ) } -function buildPromotedAttribute(attr: AttributeWithDefinitions): ComponentChildren { +function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren { const defaultLabel = <>{attr.friendlyName}:{" "}; let content: ComponentChildren; let style: CSSProperties | undefined; @@ -102,13 +102,13 @@ function buildPromotedAttribute(attr: AttributeWithDefinitions): ComponentChildr content = <>{defaultLabel}; } - return {content} + return {content} } function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] { - const promotedDefinitionAttributes = note.getAttributeDefinitions(); + const attributeDefintions = note.getAttributeDefinitions(); const result: AttributeWithDefinitions[] = []; - for (const attr of promotedDefinitionAttributes) { + for (const attr of attributeDefintions) { const def = attr.getDefinition(); const [ type, name ] = attr.name.split(":", 2); const friendlyName = def?.promotedAlias || name; diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 421f45cc4..0a79b5720 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -77,8 +77,8 @@ export function CustomNoteList({ note, isEnabled: shouldEnable props = { note, noteIds, notePath, highlightedTokens, - viewConfig: viewModeConfig[0], - saveConfig: viewModeConfig[1], + viewConfig: viewModeConfig.config, + saveConfig: viewModeConfig.storeFn, onReady: onReady ?? (() => {}), ...restProps } @@ -192,7 +192,11 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt } export function useViewModeConfig(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) { - const [ viewConfig, setViewConfig ] = useState<[T | undefined, (data: T) => void]>(); + const [ viewConfig, setViewConfig ] = useState<{ + config: T | undefined; + storeFn: (data: T) => void; + note: FNote; + }>(); useEffect(() => { if (!note || !viewType) return; @@ -200,12 +204,14 @@ export function useViewModeConfig(note: FNote | null | undefin const viewStorage = new ViewModeStorage(note, viewType); viewStorage.restore().then(config => { const storeFn = (config: T) => { - setViewConfig([ config, storeFn ]); + setViewConfig({ note, config, storeFn }); viewStorage.store(config); }; - setViewConfig([ config, storeFn ]); + setViewConfig({ note, config, storeFn }); }); }, [ note, viewType ]); + // Only expose config for the current note, avoid leaking notes when switching between them. + if (viewConfig?.note !== note) return undefined; return viewConfig; } diff --git a/apps/client/src/widgets/collections/board/api.ts b/apps/client/src/widgets/collections/board/api.ts index 14ad4d587..af88f935e 100644 --- a/apps/client/src/widgets/collections/board/api.ts +++ b/apps/client/src/widgets/collections/board/api.ts @@ -1,3 +1,4 @@ +import { BulkAction } from "@triliumnext/commons"; import { BoardViewData } from "."; import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; @@ -12,15 +13,25 @@ import { ColumnMap } from "./data"; export default class BoardApi { + private isRelationMode: boolean; + statusAttribute: string; + constructor( private byColumn: ColumnMap | undefined, public columns: string[], private parentNote: FNote, - readonly statusAttribute: string, + statusAttribute: string, private viewConfig: BoardViewData, private saveConfig: (newConfig: BoardViewData) => void, private setBranchIdToEdit: (branchId: string | undefined) => void - ) {}; + ) { + this.isRelationMode = statusAttribute.startsWith("~"); + + if (statusAttribute.startsWith("~") || statusAttribute.startsWith("#")) { + statusAttribute = statusAttribute.substring(1); + } + this.statusAttribute = statusAttribute; + }; async createNewItem(column: string, title: string) { try { @@ -42,7 +53,11 @@ export default class BoardApi { } async changeColumn(noteId: string, newColumn: string) { - await attributes.setLabel(noteId, this.statusAttribute, newColumn); + if (this.isRelationMode) { + await attributes.setRelation(noteId, this.statusAttribute, newColumn); + } else { + await attributes.setLabel(noteId, this.statusAttribute, newColumn); + } } async addNewColumn(columnName: string) { @@ -60,22 +75,20 @@ export default class BoardApi { // Add the new column to persisted data if it doesn't exist const existingColumn = this.viewConfig.columns.find(col => col.value === columnName); - if (!existingColumn) { - this.viewConfig.columns.push({ value: columnName }); - this.saveConfig(this.viewConfig); - } + if (existingColumn) return false; + this.viewConfig.columns.push({ value: columnName }); + this.saveConfig(this.viewConfig); + return true; } async removeColumn(column: string) { // Remove the value from the notes. const noteIds = this.byColumn?.get(column)?.map(item => item.note.noteId) || []; - await executeBulkActions(noteIds, [ - { - name: "deleteLabel", - labelName: this.statusAttribute - } - ]); + const action: BulkAction = this.isRelationMode + ? { name: "deleteRelation", relationName: this.statusAttribute } + : { name: "deleteLabel", labelName: this.statusAttribute } + await executeBulkActions(noteIds, [ action ]); this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column); this.saveConfig(this.viewConfig); } @@ -84,13 +97,10 @@ export default class BoardApi { const noteIds = this.byColumn?.get(oldValue)?.map(item => item.note.noteId) || []; // Change the value in the notes. - await executeBulkActions(noteIds, [ - { - name: "updateLabelValue", - labelName: this.statusAttribute, - labelValue: newValue - } - ]); + const action: BulkAction = this.isRelationMode + ? { name: "updateRelationTarget", relationName: this.statusAttribute, targetNoteId: newValue } + : { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue } + await executeBulkActions(noteIds, [ action ]); // Rename the column in the persisted data. for (const column of this.viewConfig.columns || []) { @@ -167,7 +177,11 @@ export default class BoardApi { removeFromBoard(noteId: string) { const note = froca.getNoteFromCache(noteId); if (!note) return; - return attributes.removeOwnedLabelByName(note, this.statusAttribute); + if (this.isRelationMode) { + return attributes.removeOwnedRelationByName(note, this.statusAttribute); + } else { + return attributes.removeOwnedLabelByName(note, this.statusAttribute); + } } async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) { diff --git a/apps/client/src/widgets/collections/board/card.tsx b/apps/client/src/widgets/collections/board/card.tsx index 3fff79aab..b67a00408 100644 --- a/apps/client/src/widgets/collections/board/card.tsx +++ b/apps/client/src/widgets/collections/board/card.tsx @@ -6,7 +6,8 @@ import { BoardViewContext, TitleEditor } from "."; import { ContextMenuEvent } from "../../../menus/context_menu"; import { openNoteContextMenu } from "./context_menu"; import { t } from "../../../services/i18n"; -import PromotedAttributesDisplay from "../../attribute_widgets/PromotedAttributesDisplay"; +import UserAttributesDisplay from "../../attribute_widgets/UserAttributesList"; +import { useTriliumEvent } from "../../react/hooks"; export const CARD_CLIPBOARD_TYPE = "trilium/board-card"; @@ -40,6 +41,13 @@ export default function Card({ const [ isVisible, setVisible ] = useState(true); const [ title, setTitle ] = useState(note.title); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + const row = loadResults.getEntityRow("notes", note.noteId); + if (row) { + setTitle(row.title); + } + }); + const handleDragStart = useCallback((e: DragEvent) => { e.dataTransfer!.effectAllowed = 'move'; const data: CardDragData = { noteId: note.noteId, branchId: branch.branchId, fromColumn: column, index }; @@ -109,7 +117,7 @@ export default function Card({ title={t("board_view.edit-note-title")} onClick={handleEdit} /> - + ) : ( api.dismissEditingTitle()} - multiline + mode="multiline" /> )}
diff --git a/apps/client/src/widgets/collections/board/column.tsx b/apps/client/src/widgets/collections/board/column.tsx index 4ea8768e9..f014b67bf 100644 --- a/apps/client/src/widgets/collections/board/column.tsx +++ b/apps/client/src/widgets/collections/board/column.tsx @@ -12,6 +12,7 @@ import Card, { CARD_CLIPBOARD_TYPE, CardDragData } from "./card"; import { JSX } from "preact/jsx-runtime"; import froca from "../../../services/froca"; import { DragData, TREE_CLIPBOARD_TYPE } from "../../note_tree"; +import NoteLink from "../../react/NoteLink"; interface DragContext { column: string; @@ -27,12 +28,14 @@ export default function Column({ api, onColumnHover, isAnyColumnDragging, + isInRelationMode }: { columnItems?: { note: FNote, branch: FBranch }[]; isDraggingColumn: boolean, api: BoardApi, onColumnHover?: (index: number, mouseX: number, rect: DOMRect) => void, - isAnyColumnDragging?: boolean + isAnyColumnDragging?: boolean, + isInRelationMode: boolean } & DragContext) { const [ isVisible, setVisible ] = useState(true); const { columnNameToEdit, setColumnNameToEdit, dropTarget, draggedCard, dropPosition } = useContext(BoardViewContext)!; @@ -103,7 +106,11 @@ export default function Column({ > {!isEditing ? ( <> - {column} + + {isInRelationMode + ? + : column} + {columnItems?.length ?? 0}
api.renameColumn(column, newTitle)} dismiss={() => setColumnNameToEdit?.(undefined)} + mode={isInRelationMode ? "relation" : "normal"} /> )} @@ -180,7 +188,7 @@ function AddNewItem({ column, api }: { column: string, api: BoardApi }) { placeholder={t("board_view.new-item-placeholder")} save={(title) => api.createNewItem(column, title)} dismiss={() => setIsCreatingNewItem(false)} - multiline isNewItem + mode="multiline" isNewItem /> )}
diff --git a/apps/client/src/widgets/collections/board/data.ts b/apps/client/src/widgets/collections/board/data.ts index f283e2dc7..a37487ec9 100644 --- a/apps/client/src/widgets/collections/board/data.ts +++ b/apps/client/src/widgets/collections/board/data.ts @@ -57,7 +57,8 @@ export async function getBoardData(parentNote: FNote, groupByColumn: string, per return { byColumn, - newPersistedData + newPersistedData, + isInRelationMode: groupByColumn.startsWith("~") }; } @@ -70,7 +71,7 @@ async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupB await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn, includeArchived, seenNoteIds); } - const group = note.getLabelValue(groupByColumn); + const group = note.getLabelOrRelation(groupByColumn); if (!group || seenNoteIds.has(note.noteId)) { continue; } diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 8997882ec..aaf694686 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -9,6 +9,12 @@ --card-padding: 0.6em; } +body.mobile .board-view { + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + .board-view-container { height: 100%; display: flex; @@ -31,6 +37,12 @@ flex-direction: column; } +body.mobile .board-view-container .board-column { + width: 75vw; + max-width: 300px; + scroll-snap-align: center; +} + .board-view-container .board-column.drag-over { border-color: var(--main-text-color); background-color: var(--hover-item-background-color); @@ -53,6 +65,11 @@ align-items: center; } +.board-view-container .board-column h3 a { + text-decoration: none; + color: inherit; +} + .board-view-container .board-column h3 .counter-badge { background-color: var(--muted-text-color); color: var(--main-background-color); diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 26757229d..7b939224d 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -13,6 +13,8 @@ import Column from "./column"; import BoardApi from "./api"; import FormTextArea from "../../react/FormTextArea"; import FNote from "../../../entities/fnote"; +import NoteAutocomplete from "../../react/NoteAutocomplete"; +import toast from "../../../services/toast"; export interface BoardViewData { columns?: BoardColumnData[]; @@ -42,10 +44,11 @@ interface BoardViewContextData { export const BoardViewContext = createContext(undefined); export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps) { - const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); + const [ statusAttributeWithPrefix ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status"); const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived"); const [ byColumn, setByColumn ] = useState(); const [ columns, setColumns ] = useState(); + const [ isInRelationMode, setIsRelationMode ] = useState(false); const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null); const [ dropTarget, setDropTarget ] = useState(null); const [ dropPosition, setDropPosition ] = useState<{ column: string, index: number } | null>(null); @@ -55,8 +58,8 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC const [ branchIdToEdit, setBranchIdToEdit ] = useState(); const [ columnNameToEdit, setColumnNameToEdit ] = useState(); const api = useMemo(() => { - return new Api(byColumn, columns ?? [], parentNote, statusAttribute, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); - }, [ byColumn, columns, parentNote, statusAttribute, viewConfig, saveConfig, setBranchIdToEdit ]); + return new Api(byColumn, columns ?? [], parentNote, statusAttributeWithPrefix, viewConfig ?? {}, saveConfig, setBranchIdToEdit ); + }, [ byColumn, columns, parentNote, statusAttributeWithPrefix, viewConfig, saveConfig, setBranchIdToEdit ]); const boardViewContext = useMemo(() => ({ api, parentNote, @@ -78,8 +81,9 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC ]); function refresh() { - getBoardData(parentNote, statusAttribute, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData }) => { + getBoardData(parentNote, statusAttributeWithPrefix, viewConfig ?? {}, includeArchived).then(({ byColumn, newPersistedData, isInRelationMode }) => { setByColumn(byColumn); + setIsRelationMode(isInRelationMode); if (newPersistedData) { viewConfig = { ...newPersistedData }; @@ -94,7 +98,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC }); } - useEffect(refresh, [ parentNote, noteIds, viewConfig ]); + useEffect(refresh, [ parentNote, noteIds, viewConfig, statusAttributeWithPrefix ]); const handleColumnDrop = useCallback((fromIndex: number, toIndex: number) => { const newColumns = api.reorderColumn(fromIndex, toIndex); @@ -110,7 +114,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC // Check if any changes affect our board const hasRelevantChanges = // React to changes in status attribute for notes in this board - loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId!)) || + loadResults.getAttributeRows().some(attr => attr.name === api.statusAttribute && noteIds.includes(attr.noteId!)) || // React to changes in note title loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) || // React to changes in branches for subchildren (e.g., moved, added, or removed notes) @@ -171,6 +175,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
)} )} - +
) } -function AddNewColumn({ api }: { api: BoardApi }) { +function AddNewColumn({ api, isInRelationMode }: { api: BoardApi, isInRelationMode: boolean }) { const [ isCreatingNewColumn, setIsCreatingNewColumn ] = useState(false); const addColumnCallback = useCallback(() => { @@ -209,22 +214,28 @@ function AddNewColumn({ api }: { api: BoardApi }) { : ( api.addNewColumn(columnName)} + save={async (columnName) => { + const created = await api.addNewColumn(columnName); + if (!created) { + toast.showMessage(t("board_view.column-already-exists"), undefined, "bx bx-duplicate"); + } + }} dismiss={() => setIsCreatingNewColumn(false)} isNewItem + mode={isInRelationMode ? "relation" : "normal"} /> )} ) } -export function TitleEditor({ currentValue, placeholder, save, dismiss, multiline, isNewItem }: { +export function TitleEditor({ currentValue, placeholder, save, dismiss, mode, isNewItem }: { currentValue?: string; placeholder?: string; save: (newValue: string) => void; dismiss: () => void; - multiline?: boolean; isNewItem?: boolean; + mode?: "normal" | "multiline" | "relation"; }) { const inputRef = useRef(null); const focusElRef = useRef(null); @@ -232,13 +243,11 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin const shouldDismiss = useRef(false); useEffect(() => { - focusElRef.current = document.activeElement; + focusElRef.current = document.activeElement !== document.body ? document.activeElement : null; inputRef.current?.focus(); inputRef.current?.select(); }, [ inputRef ]); - const Element = multiline ? FormTextArea : FormTextBox; - useEffect(() => { if (dismissOnNextRefreshRef.current) { dismiss(); @@ -246,31 +255,62 @@ export function TitleEditor({ currentValue, placeholder, save, dismiss, multilin } }); - return ( - ) => { - if (e.key === "Enter" || e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - shouldDismiss.current = (e.key === "Escape"); - if (focusElRef.current instanceof HTMLElement) { - focusElRef.current.focus(); + const onKeyDown = (e: TargetedKeyboardEvent | KeyboardEvent) => { + if (e.key === "Enter" || e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (focusElRef.current instanceof HTMLElement) { + shouldDismiss.current = (e.key === "Escape"); + focusElRef.current.focus(); + } else { + dismiss(); + } + } + }; + + const onBlur = (newValue: string) => { + if (!shouldDismiss.current && newValue.trim() && (newValue !== currentValue || isNewItem)) { + save(newValue); + dismissOnNextRefreshRef.current = true; + } else { + dismiss(); + } + }; + + if (mode !== "relation") { + const Element = mode === "multiline" ? FormTextArea : FormTextBox; + + return ( + + ); + } else { + return ( + { + if (e.key === "Escape") { + dismiss(); } - } - }} - onBlur={(newValue) => { - if (!shouldDismiss.current && newValue.trim() && (newValue !== currentValue || isNewItem)) { + }} + onBlur={() => dismiss()} + noteIdChanged={(newValue) => { save(newValue); - dismissOnNextRefreshRef.current = true; - } else { dismiss(); - } - }} - /> - ); + }} + /> + ); + } } diff --git a/apps/client/src/widgets/react/NoteAutocomplete.tsx b/apps/client/src/widgets/react/NoteAutocomplete.tsx index 223dbb5d4..198e69695 100644 --- a/apps/client/src/widgets/react/NoteAutocomplete.tsx +++ b/apps/client/src/widgets/react/NoteAutocomplete.tsx @@ -5,7 +5,7 @@ import type { RefObject } from "preact"; import type { CSSProperties } from "preact/compat"; import { useSyncedRef } from "./hooks"; -interface NoteAutocompleteProps { +interface NoteAutocompleteProps { id?: string; inputRef?: RefObject; text?: string; @@ -15,13 +15,15 @@ interface NoteAutocompleteProps { opts?: Omit; onChange?: (suggestion: Suggestion | null) => void; onTextChange?: (text: string) => void; + onKeyDown?: (e: KeyboardEvent) => void; + onBlur?: (newValue: string) => void; noteIdChanged?: (noteId: string) => void; noteId?: string; } -export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) { +export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) { const ref = useSyncedRef(externalInputRef); - + useEffect(() => { if (!ref.current) return; const $autoComplete = $(ref.current); @@ -57,6 +59,12 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, if (onTextChange) { $autoComplete.on("input", () => onTextChange($autoComplete[0].value)); } + if (onKeyDown) { + $autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent)); + } + if (onBlur) { + $autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? "")); + } }, [opts, container?.current]); useEffect(() => { @@ -81,4 +89,4 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder={placeholder ?? t("add_link.search_note")} /> ); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 9e6ddc905..758122ed0 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "preact/hooks"; import link, { ViewScope } from "../../services/link"; -import { useImperativeSearchHighlighlighting } from "./hooks"; +import { useImperativeSearchHighlighlighting, useTriliumEvent } from "./hooks"; interface NoteLinkOpts { className?: string; @@ -19,9 +19,11 @@ interface NoteLinkOpts { export default function NoteLink({ className, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) { const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; + const noteId = stringifiedNotePath.split("/").at(-1); const ref = useRef(null); const [ jqueryEl, setJqueryEl ] = useState>(); const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens); + const [ noteTitle, setNoteTitle ] = useState(); useEffect(() => { link.createLink(stringifiedNotePath, { @@ -30,7 +32,7 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc showNoteIcon, viewScope }).then(setJqueryEl); - }, [ stringifiedNotePath, showNotePath, title, viewScope ]); + }, [ stringifiedNotePath, showNotePath, title, viewScope, noteTitle ]); useEffect(() => { if (!ref.current || !jqueryEl) return; @@ -38,6 +40,16 @@ export default function NoteLink({ className, notePath, showNotePath, showNoteIc highlightSearch(ref.current); }, [ jqueryEl, highlightedTokens ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + // React to note title changes, but only if the title is not overwritten. + if (!title && noteId) { + const entityRow = loadResults.getEntityRow("notes", noteId); + if (entityRow) { + setNoteTitle(entityRow.title); + } + } + }); + if (style) { jqueryEl?.css(style); } diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes.html index d46a3e51c..3825c8af8 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes.html @@ -5,14 +5,14 @@

In Trilium, attributes are key-value pairs assigned to notes, providing additional metadata or functionality. There are two primary types of attributes:

    -
  1. +
  2. Labels can be used for a variety of purposes, such as storing metadata or configuring the behavior of notes. Labels are also searchable, enhancing note retrieval.

    For more information, including predefined labels, see Labels.

  3. -
  4. +
  5. Relations define connections between notes, similar to links. These can be used for metadata and scripting purposes.

    @@ -23,6 +23,30 @@

These attributes play a crucial role in organizing, categorizing, and enhancing the functionality of notes.

+

Types of attributes

+

Conceptually there are two types of attributes (applying to both labels + and relations):

+
    +
  1. System attributes +
    As the name suggest, these attributes have a special meaning since they + are interpreted by Trilium. For example the color attribute + will change the color of the note as displayed in the Note Tree and + links, and iconClass will change the icon of a note. +
     
  2. +
  3. User-defined attributes +
    These are free-form labels or relations that can be used by the user. + They can be used purely for categorization purposes (especially if combined + with Search), + or they can be given meaning through the use of Scripting.
  4. +
+

In practice, Trilium makes no direct distinction of whether an attribute + is a system one or a user-defined one. A label or relation is considered + a system attribute if it matches one of the built-in names (e.g. like the + aforementioned iconClass). Keep this in mind when creating +  Promoted Attributes in + order not to accidentally alter a system attribute (unless intended).

Viewing the list of attributes

Both the labels and relations for the current note are displayed in the Owned Attributes section of the Ribbon, @@ -31,13 +55,15 @@ only be viewed.

In the list of attributes, labels are prefixed with the # character whereas relations are prefixed with the ~ character.

+

Attribute Definitions and Promoted Attributes

+

Promoted Attributes create + a form-like editing experience for attributes, which makes it easy to enhancing + the organization and management of attributes

Multiplicity

Attributes in Trilium can be "multi-valued", meaning multiple attributes - with the same name can co-exist.

-

Attribute Definitions and Promoted Attributes

-

Special labels create "label/attribute" definitions, enhancing the organization - and management of attributes. For more details, see Promoted Attributes.

+ with the same name can co-exist. This can be combined with Promoted Attributes to + easily add them.

Attribute Inheritance

Trilium supports attribute inheritance, allowing child notes to inherit attributes from their parents. For more information, see  + +

Promoted attributes are attributes which - are considered important and thus are "promoted" onto the main note UI. - See example below:

-

- -

-

You can see the note having kind of form with several fields. Each of - these is just regular attribute, the only difference is that they appear - on the note itself.

+ are displayed prominently in the UI which allow them to be easily viewed + and edited.

+

One way of seeing promoted attributes is as a kind of form with several + fields. Each field is just regular attribute, the only difference is that + they appear on the note itself.

Attributes can be pretty useful since they allow for querying and script automation etc. but they are also inconveniently hidden. This allows you to select few of the important ones and push them to the front of the user.

-

Now, how do we make attribute to appear on the UI?

Attribute definition

-

Attribute is always name-value pair where both name and value are strings.

-

Attribute definition specifies how should this value be interpreted - - is it just string, or is it a date? Should we allow multiple values or - note? And importantly, should we promote the attribute or not?

-

- -

-

You can notice tag attribute definition. These "definition" attributes - define how the "value" attributes should behave.

+

In order to have promoted attributes, there needs to be a way to define + them.

+
+ +
+

Technically, attributes are only name-value pairs where both name and + value are strings.

+

The Attribute definition specifies how should this value be interpreted:

+
    +
  • Is it just string, or is it a date?
  • +
  • Should we allow multiple values or note?
  • +
  • Should we promote the attribute or not?
  • +
+

Creating a new promoted attribute definition

+

To create a new promoted attribute:

+
    +
  1. Go to a note.
  2. +
  3. Go to Owned Attributes in the Ribbon.
  4. +
  5. Press the + button.
  6. +
  7. Select either Add new label definition or Add new relation definition.
  8. +
  9. Select the name which will be name of the label or relation that will + be created when the promoted attribute is edited.
  10. +
  11. Ensure Promoted is checked in order to display it at the top of + notes.
  12. +
  13. Optionally, choose an Alias which will be displayed next to the + promoted attribute instead of the attribute name. Generally it's best to + choose a “user-friendly” name since it can contain spaces and other characters + which are not supported as attribute names.
  14. +
  15. Check Inheritable to apply it to this note and all its descendants. + To keep it only for the current note, un-check it.
  16. +
  17. Press “Save & Close” to apply the changes.
  18. +
+

How attribute definitions actually work

+

When a new promoted attribute definition is created, it creates a corresponding + label prefixed with either label or relation, depending + on the definition type:

#label:myColor(inheritable)="promoted,alias=Color,multi,color"
+

The only purpose of the attribute definition is to set up a template. + If the attribute was marked as promoted, then it's also displayed to the + user for easy editing.

+
+ + + + + + + + + + + + + + + +
+
+ +
+
Notice how the promoted attribute definition only creates a “Due date” + box above the text content.
+
+ +
+
Once a value is set by the user, a new label (or relation, depending on + the type) is created. The name of the attribute matches one set when creating + the promoted attribute.
+

So there's one attribute for value and one for definition. But notice - how definition attribute is Inheritable, - meaning that it's also applied to all descendant note. So in a way, this - definition is used for the whole subtree while "value" attributes are applied - only for this note.

+ how an definition attribute can be made Inheritable, + meaning that it's also applied to all descendant notes. In this case, the + definition used for the whole sub-tree while "value" attributes are for + each not individually.

+

Using system attributes

+

It's possible to create promoted attributes out of system attributes, + to be able to easily alter them.

+

Here are a few practical examples:

+
    +
  • Collections already + make use of this practice, for example: +
      +
    • Calendars add “Start Date”, “End Date”, “Start Time” and “End Time” as + promoted attributes. These map to system attributes such as startDate which + are then interpreted by the calendar view.
    • +
    • Presentation adds + a “Background” promoted attribute for each of the slide to easily be able + to customize.
    • +
    +
  • +
  • The Trilium documentation (which is edited in Trilium) uses a promoted + attribute to be able to easily edit the #shareAlias (see  + Sharing) in order to form clean URLs.
  • +
  • If you always edit a particular system attribute such as #color, + simply create a promoted attribute for it to make it easier.
  • +

Inverse relation

Some relations always occur in pairs - my favorite example is on the family. If you have a note representing husband and note representing wife, then @@ -33,7 +120,7 @@ This is bidirectional relationship - meaning that if a relation is pointing from husband to wife then there should be always another relation pointing from wife to husband.

-

Another example is with parent - child relationship. Again these always +

Another example is with parent-child relationship. Again these always occur in pairs, but in this case it's not exact same relation - the one going from parent to child might be called isParentOf and the other one going from child to parent might be called isChildOf.

diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_image.png index ecb5f2d6d..148f98b8a 100644 Binary files a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_image.png and b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_image.png differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_promot.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_promot.png deleted file mode 100644 index cca868320..000000000 Binary files a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes_promot.png and /dev/null differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/1_Kanban Board_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/1_Kanban Board_image.png new file mode 100644 index 000000000..1ccb0a3cc Binary files /dev/null and b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/1_Kanban Board_image.png differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/2_Kanban Board_image.png b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/2_Kanban Board_image.png new file mode 100644 index 000000000..be4b027d2 Binary files /dev/null and b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/2_Kanban Board_image.png differ diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Geo Map.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Geo Map.html index 23245ddd1..58f9014d1 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Geo Map.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Geo Map.html @@ -1,8 +1,11 @@

Creating a new geo map

- - - - - - - - - - - - - - - - - - - - -
1 -
- -
-
Right click on any note on the note tree and select Insert child noteGeo Map (beta).
2 -
- -
-
By default the map will be empty and will show the entire world.
- +
+ + + + + + + + + + + + + + + + + + + + +
   
1 +
+ +
+
Right click on any note on the note tree and select Insert child noteGeo Map (beta).
2 +
+ +
+
By default the map will be empty and will show the entire world.
+

Repositioning the map

    -
  • Click and drag the map in order to move across the map.
  • -
  • Use the mouse wheel, two-finger gesture on a touchpad or the +/- buttons +
  • Click and drag the map in order to move across the map.
  • +
  • Use the mouse wheel, two-finger gesture on a touchpad or the +/- buttons on the top-left to adjust the zoom.

The position on the map and the zoom are saved inside the map note and restored when visiting again the note.

Adding a marker using the map

Adding a new note using the plus button

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1To create a marker, first navigate to the desired point on the map. Then - press the - button in the Floating buttons (top-right) - area.    -
-
If the button is not visible, make sure the button section is visible - by pressing the chevron button ( - ) in the top-right of the map.
2 - - Once pressed, the map will enter in the insert mode, as illustrated by - the notification.       -
-
Simply click the point on the map where to place the marker, or the Escape - key to cancel.
3 - - Enter the name of the marker/note to be created.
4 - - Once confirmed, the marker will show up on the map and it will also be - displayed as a child note of the map.
- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
   
1To create a marker, first navigate to the desired point on the map. Then + press the + button in the Floating buttons (top-right) + area.     +
+
If the button is not visible, make sure the button section is visible + by pressing the chevron button ( + ) in the top-right of the map.
 
2 + + Once pressed, the map will enter in the insert mode, as illustrated by + the notification.        +
+
Simply click the point on the map where to place the marker, or the Escape + key to cancel.
3 + + Enter the name of the marker/note to be created.
4 + + Once confirmed, the marker will show up on the map and it will also be + displayed as a child note of the map.
+

Adding a new note using the contextual menu

    -
  1. Right click anywhere on the map, where to place the newly created marker +
  2. Right click anywhere on the map, where to place the newly created marker (and corresponding note).
  3. -
  4. Select Add a marker at this location.
  5. -
  6. Enter the name of the newly created note.
  7. -
  8. The map should be updated with the new marker.
  9. +
  10. Select Add a marker at this location.
  11. +
  12. Enter the name of the neNote Treewly + created note.
  13. +
  14. The map should be updated with the new marker.

Adding an existing note on note from the note tree

    -
  1. Select the desired note in the Note Tree.
  2. -
  3. Hold the mouse on the note and drag it to the map to the desired location.
  4. -
  5. The map should be updated with the new marker.
  6. +
  7. Select the desired note in the Note Tree.
  8. +
  9. Hold the mouse on the note and drag it to the map to the desired location.
  10. +
  11. The map should be updated with the new marker.

This works for:

    -
  • Notes that are not part of the geo map, case in which a clone will +
  • Notes that are not part of the geo map, case in which a clone will be created.
  • -
  • Notes that are a child of the geo map but not yet positioned on the map.
  • -
  • Notes that are a child of the geo map and also positioned, case in which +
  • Notes that are a child of the geo map but not yet positioned on the map.
  • +
  • Notes that are a child of the geo map and also positioned, case in which the marker will be relocated to the new position.