Merge branch 'main' into patch-1

This commit is contained in:
Elian Doran
2025-11-15 21:24:24 +02:00
committed by GitHub
68 changed files with 1456 additions and 814 deletions

View File

@@ -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

View File

@@ -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
};

View File

@@ -29,6 +29,8 @@ async function getActionsForScope(scope: string) {
}
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
if (!$el[0]) return [];
const actions = await getActionsForScope(scope);
const bindings: ShortcutBinding[] = [];

View File

@@ -150,11 +150,16 @@ async function createLink(notePath: string | undefined, options: CreateLinkOptio
$container.append($noteLink);
if (showNotePath) {
const resolvedPathSegments = (await treeService.resolveNotePathToSegments(notePath)) || [];
resolvedPathSegments.pop(); // Remove last element
let pathSegments: string[];
if (notePath == "root") {
pathSegments = ["⌂"];
} else {
const resolvedPathSegments = (await treeService.resolveNotePathToSegments(notePath)) || [];
resolvedPathSegments.pop(); // Remove last element
const resolvedPath = resolvedPathSegments.join("/");
const pathSegments = await treeService.getNotePathTitleComponents(resolvedPath);
const resolvedPath = resolvedPathSegments.join("/");
pathSegments = await treeService.getNotePathTitleComponents(resolvedPath);
}
if (pathSegments) {
if (pathSegments.length) {

View File

@@ -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`/

View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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 && (
<div className="promoted-attributes">
{promotedDefinitionAttributes?.map(attr => buildPromotedAttribute(attr))}
export default function UserAttributesDisplay({ note, ignoredAttributes }: UserAttributesListProps) {
const userAttributes = useNoteAttributesWithDefinitions(note, ignoredAttributes);
return userAttributes?.length > 0 && (
<div className="user-attributes">
{userAttributes?.map(attr => buildUserAttribute(attr))}
</div>
)
}
function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
const [ promotedDefinitionAttributes, setPromotedDefinitionAttributes ] = useState<AttributeWithDefinitions[]>(getAttributesWithDefinitions(note, attributesToIgnore));
const [ userAttributes, setUserAttributes ] = useState<AttributeWithDefinitions[]>(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 (
<span key={attr.friendlyName} className={`promoted-attribute type-${className}`} style={style}>
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
{children}
</span>
)
}
function buildPromotedAttribute(attr: AttributeWithDefinitions): ComponentChildren {
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
const defaultLabel = <><strong>{attr.friendlyName}:</strong>{" "}</>;
let content: ComponentChildren;
let style: CSSProperties | undefined;
@@ -102,13 +102,13 @@ function buildPromotedAttribute(attr: AttributeWithDefinitions): ComponentChildr
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
}
return <PromotedAttribute attr={attr} style={style}>{content}</PromotedAttribute>
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
}
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;

View File

@@ -77,8 +77,8 @@ export function CustomNoteList<T extends object>({ 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<T extends object>(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<T extends object>(note: FNote | null | undefin
const viewStorage = new ViewModeStorage<T>(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;
}

View File

@@ -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) {

View File

@@ -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}
/>
<PromotedAttributesDisplay note={note} ignoredAttributes={[api.statusAttribute]} />
<UserAttributesDisplay note={note} ignoredAttributes={[api.statusAttribute]} />
</>
) : (
<TitleEditor
@@ -119,7 +127,7 @@ export default function Card({
setTitle(newTitle);
}}
dismiss={() => api.dismissEditingTitle()}
multiline
mode="multiline"
/>
)}
</div>

View File

@@ -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 ? (
<>
<span className="title">{column}</span>
<span className="title">
{isInRelationMode
? <NoteLink notePath={column} showNoteIcon />
: column}
</span>
<span className="counter-badge">{columnItems?.length ?? 0}</span>
<div className="spacer" />
<span
@@ -117,6 +124,7 @@ export default function Column({
currentValue={column}
save={newTitle => api.renameColumn(column, newTitle)}
dismiss={() => setColumnNameToEdit?.(undefined)}
mode={isInRelationMode ? "relation" : "normal"}
/>
)}
</h3>
@@ -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
/>
)}
</div>

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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<BoardViewContextData | undefined>(undefined);
export default function BoardView({ note: parentNote, noteIds, viewConfig, saveConfig }: ViewModeProps<BoardViewData>) {
const [ statusAttribute ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status");
const [ statusAttributeWithPrefix ] = useNoteLabelWithDefault(parentNote, "board:groupBy", "status");
const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived");
const [ byColumn, setByColumn ] = useState<ColumnMap>();
const [ columns, setColumns ] = useState<string[]>();
const [ isInRelationMode, setIsRelationMode ] = useState(false);
const [ draggedCard, setDraggedCard ] = useState<{ noteId: string, branchId: string, fromColumn: string, index: number } | null>(null);
const [ dropTarget, setDropTarget ] = useState<string | null>(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<string>();
const [ columnNameToEdit, setColumnNameToEdit ] = useState<string>();
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<BoardViewContextData>(() => ({
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
<div className="column-drop-placeholder show" />
)}
<Column
isInRelationMode={isInRelationMode}
api={api}
column={column}
columnIndex={index}
@@ -185,14 +190,14 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC
<div className="column-drop-placeholder show" />
)}
<AddNewColumn api={api} />
<AddNewColumn api={api} isInRelationMode={isInRelationMode} />
</div>
</BoardViewContext.Provider>
</div>
)
}
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 }) {
: (
<TitleEditor
placeholder={t("board_view.add-column-placeholder")}
save={(columnName) => 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"}
/>
)}
</div>
)
}
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<any>(null);
const focusElRef = useRef<Element>(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 (
<Element
inputRef={inputRef}
currentValue={currentValue ?? ""}
placeholder={placeholder}
autoComplete="trilium-title-entry" // forces the auto-fill off better than the "off" value.
rows={multiline ? 4 : undefined}
onKeyDown={(e: TargetedKeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
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<HTMLInputElement | HTMLTextAreaElement> | 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 (
<Element
inputRef={inputRef}
currentValue={currentValue ?? ""}
placeholder={placeholder}
autoComplete="trilium-title-entry" // forces the auto-fill off better than the "off" value.
rows={mode === "multiline" ? 4 : undefined}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
);
} else {
return (
<NoteAutocomplete
inputRef={inputRef}
noteId={currentValue ?? ""}
opts={{
hideAllButtons: true,
allowCreatingNotes: true
}}
onKeyDown={(e) => {
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();
}
}}
/>
);
}}
/>
);
}
}

View File

@@ -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<HTMLInputElement>;
text?: string;
@@ -15,13 +15,15 @@ interface NoteAutocompleteProps {
opts?: Omit<Options, "container">;
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<HTMLInputElement>(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")} />
</div>
);
}
}

View File

@@ -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<HTMLSpanElement>(null);
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
const [ noteTitle, setNoteTitle ] = useState<string>();
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);
}

View File

@@ -1,7 +1,7 @@
import { HTMLProps, RefObject, useEffect, useImperativeHandle, useRef, useState } from "preact/compat";
import { PopupEditor, ClassicEditor, EditorWatchdog, type WatchdogConfig, CKTextEditor, TemplateDefinition } from "@triliumnext/ckeditor5";
import { buildConfig, BuildEditorOptions } from "./config";
import { useLegacyImperativeHandlers, useSyncedRef } from "../../react/hooks";
import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef } from "../../react/hooks";
import link from "../../../services/link";
import froca from "../../../services/froca";
@@ -38,6 +38,9 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
const watchdogRef = useRef<EditorWatchdog>(null);
const [ editor, setEditor ] = useState<CKTextEditor>();
const { parentComponent } = useNoteContext();
useKeyboardShortcuts("text-detail", containerRef, parentComponent);
useImperativeHandle(editorApi, () => ({
hasSelection() {

View File

@@ -196,14 +196,12 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
});
}
useKeyboardShortcuts("text-detail", containerRef, parentComponent);
useTriliumEvent("insertDateTimeToText", ({ ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;
const date = new Date();
const customDateTimeFormat = options.get("customDateTimeFormat");
const dateString = utils.formatDateTime(date, customDateTimeFormat);
console.log("Insert text ", ntxId, eventNtxId, dateString);
addTextToEditor(dateString);
});
useTriliumEvent("addTextToActiveEditor", ({ text }) => {