mirror of
https://github.com/zadam/trilium.git
synced 2026-07-04 13:19:08 +02:00
Merge branch 'main' into patch-1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user