mirror of
https://github.com/zadam/trilium.git
synced 2026-09-12 19:50:20 +05:00
Board improvements & app-wide icon alignment improvements (#11449)
This commit is contained in:
commit
dc08b2f96e
@ -71,6 +71,19 @@ export interface NoteContextDataMap {
|
||||
annotations: PdfAnnotationInfo[];
|
||||
scrollToAnnotation(annotationId: string, pageNumber: number): void;
|
||||
};
|
||||
/** Published by a board, so the right pane can list its columns and scroll the board to one. */
|
||||
boardColumns: {
|
||||
/** The columns in the order the board draws them. */
|
||||
columns: {
|
||||
/** What identifies the column, which is the value its cards carry. */
|
||||
value: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
/** How many cards it holds, counting what an active filter leaves in. */
|
||||
count: number;
|
||||
}[];
|
||||
scrollToColumn(column: string): void;
|
||||
};
|
||||
saveState: {
|
||||
state: SaveState;
|
||||
};
|
||||
|
||||
@ -132,6 +132,44 @@ describe("contextMenu", () => {
|
||||
expect(menu?.querySelectorAll(".dropdown-item .use-note-color")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("puts itself away once a submenu's parent acts, and stays up for one that only folds", async () => {
|
||||
const menu = buildPage();
|
||||
const contextMenu = await buildContextMenu();
|
||||
const picked: string[] = [];
|
||||
|
||||
const show = () => contextMenu.show({
|
||||
x: 10,
|
||||
y: 10,
|
||||
selectMenuItemHandler: (item) => { picked.push(String(item.title)); },
|
||||
items: [
|
||||
{ title: "Open note", command: "openNoteInNewTab", items: [ { title: "New tab" } ] },
|
||||
{ title: "More", items: [ { title: "Archived" } ] }
|
||||
]
|
||||
});
|
||||
const pressRow = (title: string) => {
|
||||
// Read off the row's own line: its text also holds whatever its submenu lists.
|
||||
const row = [ ...menu?.querySelectorAll("li.dropdown-item") ?? [] ]
|
||||
.find((item) => item.querySelector(":scope > span")?.textContent?.trim() === title);
|
||||
expect(row, title).toBeTruthy();
|
||||
const press = new MouseEvent("mousedown", { bubbles: true, button: 0 });
|
||||
// happy-dom leaves the legacy `which` unset, which is what the menu reads for the
|
||||
// primary button.
|
||||
Object.defineProperty(press, "which", { value: 1 });
|
||||
row?.dispatchEvent(press);
|
||||
};
|
||||
|
||||
await show();
|
||||
pressRow("Open note");
|
||||
expect(picked).toEqual([ "Open note" ]);
|
||||
expect(contextMenu.isShown()).toBe(false);
|
||||
|
||||
await show();
|
||||
pressRow("More");
|
||||
expect(picked).toEqual([ "Open note", "More" ]);
|
||||
// Nothing ran, so the menu is left standing for the submenu to be reached from.
|
||||
expect(contextMenu.isShown()).toBe(true);
|
||||
});
|
||||
|
||||
it("says whether it is up, for a host whose own press would otherwise not know", async () => {
|
||||
buildPage();
|
||||
const contextMenu = await buildContextMenu();
|
||||
|
||||
@ -330,7 +330,9 @@ class ContextMenu {
|
||||
|
||||
const $link = $("<span>")
|
||||
.append($icon)
|
||||
.append(" ") // some space between icon and text
|
||||
// An element, not spaces: in a flex row, text merges with a plain title but is
|
||||
// trimmed before one boxed by `menuName()`, indenting the two kinds of row apart.
|
||||
.append($("<span>").addClass("tn-menu-gap"))
|
||||
.append(item.title);
|
||||
|
||||
if ("badges" in item && item.badges) {
|
||||
@ -393,8 +395,12 @@ class ContextMenu {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent submenu from failing to expand on mobile
|
||||
if (!("items" in item && item.items)) {
|
||||
// A submenu's parent stays open so that it can still be expanded. One carrying a
|
||||
// command or handler of its own is dismissed like any other item once it has run.
|
||||
const opensSubmenu = "items" in item && !!item.items;
|
||||
const acts = ("handler" in item && !!item.handler)
|
||||
|| ("command" in item && !!item.command);
|
||||
if (!opensSubmenu || acts) {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
|
||||
@ -92,7 +92,11 @@ describe("getItems", () => {
|
||||
it("folds the three places into one submenu, quick edit standing on its own", () => {
|
||||
const open = linkContextMenu.getOpenNoteItem(contextMenuEvent());
|
||||
|
||||
expect(open).toMatchObject({ title: "link_context_menu.open_note" });
|
||||
// The entry acts as well as folding: picking it opens the note where it is opened most.
|
||||
expect(open).toMatchObject({
|
||||
title: "link_context_menu.open_note",
|
||||
command: "openNoteInNewTab"
|
||||
});
|
||||
expect("items" in open && open.items?.map((item) => "command" in item && item.command))
|
||||
.toEqual([ "openNoteInNewTab", "openNoteInNewSplit", "openNoteInNewWindow" ]);
|
||||
expect(linkContextMenu.getQuickEditItem()).toMatchObject({
|
||||
|
||||
@ -41,12 +41,14 @@ function getQuickEditItem(): MenuItem<CommandNames> {
|
||||
* The same places, folded into one submenu, for a menu that lists entries of its own beside them.
|
||||
*
|
||||
* The items keep their commands, so `handleLinkContextMenuItem` handles them from a submenu as it
|
||||
* does from the top level.
|
||||
* does from the top level. The entry carries the first of those commands itself, so that picking it
|
||||
* opens a new tab without going into the submenu for the place it is opened in most often.
|
||||
*/
|
||||
function getOpenNoteItem(e: ContextMenuEvent | GeoMouseEvent): MenuItem<CommandNames> {
|
||||
return {
|
||||
title: t("link_context_menu.open_note"),
|
||||
uiIcon: "bx bx-link-external",
|
||||
command: "openNoteInNewTab",
|
||||
items: getOpenItems(e)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1702,6 +1702,12 @@ body.mobile .dropdown-submenu > .dropdown-menu {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* What stands between a menu item's icon and its title. */
|
||||
.dropdown-item .tn-menu-gap {
|
||||
flex: none;
|
||||
width: 0.5em;
|
||||
}
|
||||
|
||||
/* A name the user wrote, boxed by `menuName()`: clipped rather than widening the menu. */
|
||||
.dropdown-item .tn-menu-name {
|
||||
display: block;
|
||||
|
||||
@ -411,6 +411,10 @@
|
||||
--board-item-hover-background-saturation: 25%;
|
||||
--board-item-hover-background-lightness: 35%;
|
||||
|
||||
--board-drop-hint-background-saturation: 20%;
|
||||
--board-drop-hint-background-lightness: 45%;
|
||||
--board-drop-hint-color: #ffffffc9;
|
||||
|
||||
--board-focus-outline-saturation: 60%;
|
||||
--board-focus-outline-lightness: 70%;
|
||||
|
||||
|
||||
@ -410,6 +410,10 @@
|
||||
--board-item-hover-background-saturation: 100%;
|
||||
--board-item-hover-background-lightness: 98.5%;
|
||||
|
||||
--board-drop-hint-background-saturation: 50%;
|
||||
--board-drop-hint-background-lightness: 75%;
|
||||
--board-drop-hint-color: #000000bf;
|
||||
|
||||
--board-focus-outline-saturation: 50%;
|
||||
--board-focus-outline-lightness: 34%;
|
||||
|
||||
|
||||
@ -739,6 +739,7 @@
|
||||
"is_hidden": "Keeps a task state out of the pickers, without deleting it or the notes already in it.",
|
||||
"max_nesting_depth": "How many levels of children a table shows. Notes cannot be reordered while it is set.",
|
||||
"include_archived": "Includes archived notes in this collection, which are otherwise left out.",
|
||||
"board_card_width": "For board collections, how wide the columns are drawn: <code>narrow</code>, <code>medium</code> or <code>wide</code>. Ignored on mobile, which draws them at a width of its own.",
|
||||
"enable_inbox_column": "For board collections, shows the inbox column, which lists all child notes that are not assigned to a board column.",
|
||||
"board_card_redirect_to": "On a board card, opening the card jumps to the note this points at instead of opening the card itself.",
|
||||
"sort_columns": "For board collections, the order the columns sort their cards in, which a column follows unless it is given an order of its own. Holds <code>title</code>, <code>creationDate</code> or <code>attr:</code> followed by a promoted attribute's name.",
|
||||
@ -3428,7 +3429,7 @@
|
||||
"insert-above": "Insert new above",
|
||||
"insert-below": "Insert new below",
|
||||
"move-to-top": "Move to top",
|
||||
"more-columns": "More states…",
|
||||
"more-columns": "More…",
|
||||
"delete-column": "Delete column...",
|
||||
"archive-column": "Archive column",
|
||||
"unarchive-column": "Unarchive column",
|
||||
@ -3494,6 +3495,7 @@
|
||||
"change-note-icon": "Change icon",
|
||||
"column-menu": "Column menu",
|
||||
"column-already-exists": "This column already exists on the board.",
|
||||
"column-name-taken": "The column named “{{- column}}” already exists.",
|
||||
"column-definition-save-error": "The columns were saved on the board, but could not be saved to the status attribute.",
|
||||
"save-error": "The change could not be saved.",
|
||||
"hints": {
|
||||
@ -3528,6 +3530,14 @@
|
||||
"sort": "Sort",
|
||||
"sort-manually": "Manually",
|
||||
"default-card-order": "Default card order",
|
||||
"columns-title": "Columns",
|
||||
"columns-empty": "This board has no columns yet.",
|
||||
"column-width": "Column width",
|
||||
"drop-as-first-item": "Drop as the first item",
|
||||
"drop-as-last-item": "Drop as the last item",
|
||||
"column-width-narrow": "Narrow (default)",
|
||||
"column-width-medium": "Medium",
|
||||
"column-width-wide": "Wide",
|
||||
"default-card-order-description": "How cards are sorted in each column, unless you choose different sorting options for a particular column.",
|
||||
"sort-board-default": "Board's default",
|
||||
"reset-column-sorting": "Reset sorting options to default for all columns",
|
||||
|
||||
@ -157,6 +157,7 @@ export const ATTR_HELP: AttrHelpMap = {
|
||||
maxNestingDepth: t("attribute_detail.max_nesting_depth"),
|
||||
includeArchived: t("attribute_detail.include_archived"),
|
||||
enableInboxColumn: t("attribute_detail.enable_inbox_column"),
|
||||
boardCardWidth: t("attribute_detail.board_card_width"),
|
||||
sortColumns: t("attribute_detail.sort_columns"),
|
||||
sortColumnsDescending: t("attribute_detail.sort_columns_descending"),
|
||||
"calendar:view": t("attribute_detail.calendar_view"),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import FAttribute from "../../../entities/fattribute";
|
||||
@ -1883,6 +1883,62 @@ describe("renaming a column that names itself", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("renaming a column to a name already taken", () => {
|
||||
/**
|
||||
* A column is identified by the name its cards carry, so writing another column's name over it
|
||||
* would merge the two. The rename is refused and reported instead.
|
||||
*/
|
||||
it("refuses the rename, says so and leaves both columns alone", async () => {
|
||||
const { api, saved } = createApi(
|
||||
{ columns: [ { value: "To Do" }, { value: "Done" } ] }, [ "To Do", "Done" ]);
|
||||
const put = vi.spyOn(server, "put").mockResolvedValue(undefined);
|
||||
const message = vi.spyOn(toast, "showMessage").mockReturnValue(undefined);
|
||||
|
||||
expect(await api.setColumnTitle("To Do", "Done")).toBe(false);
|
||||
|
||||
expect(message).toHaveBeenCalledWith(
|
||||
"board_view.column-name-taken", undefined, "bx bx-duplicate");
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(saved).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The name a column is known by, not only the value its cards carry: the inbox has no value
|
||||
* and is named by `displayName`, so a second column under that name reads as a duplicate.
|
||||
*/
|
||||
it("counts a column with no cards yet, and the name the inbox goes by", async () => {
|
||||
const { api, saved } = createApi(
|
||||
{
|
||||
columns: [
|
||||
{ value: "", displayName: "Unsorted" },
|
||||
{ value: "To Do" },
|
||||
{ value: "Done" }
|
||||
]
|
||||
},
|
||||
// "Done" is stored but holds no cards, so the board does not derive it.
|
||||
[ "", "To Do" ]
|
||||
);
|
||||
vi.spyOn(server, "put").mockResolvedValue(undefined);
|
||||
vi.spyOn(toast, "showMessage").mockReturnValue(undefined);
|
||||
|
||||
expect(await api.setColumnTitle("To Do", "Done")).toBe(false);
|
||||
expect(await api.setColumnTitle("To Do", "Unsorted")).toBe(false);
|
||||
expect(await api.setColumnTitle("", "Done")).toBe(false);
|
||||
expect(saved).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes a free name, and a column keeping the one it has", async () => {
|
||||
const { api } = createApi(
|
||||
{ columns: [ { value: "To Do" }, { value: "Done" } ] }, [ "To Do", "Done" ]);
|
||||
const put = vi.spyOn(server, "put").mockResolvedValue(undefined);
|
||||
|
||||
await api.setColumnTitle("To Do", "Doing");
|
||||
await api.setColumnTitle("Done", "Done");
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filing a card under the inbox", () => {
|
||||
/**
|
||||
* Landing in the inbox means carrying no value at all. A relation the card takes from elsewhere
|
||||
@ -2384,3 +2440,105 @@ describe("a selection of cards", () => {
|
||||
expect(branches.moveAfterBranch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("how wide the board draws its columns", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("reads the label, falling back to the default for a width it does not offer", () => {
|
||||
const width = (label?: string) => createApi(
|
||||
{}, [], buildNote(label ? { title: "Board", "#boardCardWidth": label }
|
||||
: { title: "Board" })).api.columnWidth;
|
||||
|
||||
expect(width()).toBe("narrow");
|
||||
expect(width("medium")).toBe("medium");
|
||||
expect(width("wide")).toBe("wide");
|
||||
expect(width("enormous")).toBe("narrow");
|
||||
});
|
||||
|
||||
it("writes the label for a width other than the default", async () => {
|
||||
const board = buildNote({ title: "Board" });
|
||||
const setLabel = vi.spyOn(attributes, "setLabel").mockResolvedValue(undefined);
|
||||
const { api } = createApi({}, [], board);
|
||||
|
||||
await api.setColumnWidth("wide");
|
||||
|
||||
expect(setLabel).toHaveBeenCalledWith(board.noteId, "boardCardWidth", "wide");
|
||||
});
|
||||
|
||||
/** Kept tidy: a board drawn at the default width carries no label for it at all. */
|
||||
it("takes the label off for the default width", async () => {
|
||||
const board = buildNote({ title: "Board", "#boardCardWidth": "wide" });
|
||||
const setLabel = vi.spyOn(attributes, "setLabel").mockResolvedValue(undefined);
|
||||
const removeLabel = vi.spyOn(attributes, "removeOwnedLabelByName")
|
||||
.mockResolvedValue(true);
|
||||
const { api } = createApi({}, [], board);
|
||||
|
||||
await api.setColumnWidth("narrow");
|
||||
|
||||
expect(removeLabel).toHaveBeenCalledWith(board, "boardCardWidth");
|
||||
expect(setLabel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Dropping its own label there would hand the board the inherited width straight back. */
|
||||
it("writes the default out where the board inherits another width", async () => {
|
||||
const parent = buildNote({
|
||||
title: "Parent",
|
||||
"#boardCardWidth(inheritable)": "wide",
|
||||
children: [ { title: "Board" } ]
|
||||
});
|
||||
const board = froca.getNoteFromCache(parent.getChildNoteIds()[0]);
|
||||
if (!board) throw new Error("expected the board to be in froca");
|
||||
const setLabel = vi.spyOn(attributes, "setLabel").mockResolvedValue(undefined);
|
||||
const removeLabel = vi.spyOn(attributes, "removeOwnedLabelByName")
|
||||
.mockResolvedValue(true);
|
||||
const { api } = createApi({}, [], board);
|
||||
|
||||
expect(api.columnWidth).toBe("wide");
|
||||
|
||||
await api.setColumnWidth("narrow");
|
||||
|
||||
expect(setLabel).toHaveBeenCalledWith(board.noteId, "boardCardWidth", "narrow");
|
||||
expect(removeLabel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the columns as the right pane lists them", () => {
|
||||
it("names each column, gives it its icon and counts the cards it holds", () => {
|
||||
const board = buildNote({ title: "Board" });
|
||||
const cards = new Map([
|
||||
[ "", [ { note: { noteId: "a" } }, { note: { noteId: "b" } } ] ],
|
||||
[ "To Do", [ { note: { noteId: "c" } } ] ]
|
||||
]) as unknown as ColumnMap;
|
||||
const { api } = createApi(
|
||||
{
|
||||
columns: [
|
||||
{ value: "", displayName: "Unsorted" },
|
||||
{ value: "To Do", icon: "bx bx-star" },
|
||||
{ value: "Done" }
|
||||
]
|
||||
},
|
||||
[ "", "To Do", "Done" ], board, "status", cards);
|
||||
|
||||
expect(api.getColumnOutline([ "", "To Do", "Done" ])).toEqual([
|
||||
{ value: "", title: "Unsorted", icon: "bx bxs-inbox", count: 2 },
|
||||
{ value: "To Do", title: "To Do", icon: "bx bx-star", count: 1 },
|
||||
// A column the board draws no cards for still stands, and counts none.
|
||||
{ value: "Done", title: "Done", icon: DEFAULT_COLUMN_ICON, count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
/** A relation board keys its columns by note id, which says nothing to a reader on its own. */
|
||||
it("names a relation board's columns by the notes they point at", () => {
|
||||
const target = buildNote({ title: "Alice", "#iconClass": "bx bx-user" });
|
||||
const board = buildNote({ title: "Board" });
|
||||
const { api } = createApi({}, [ "", target.noteId ], board, "~assignee");
|
||||
|
||||
expect(api.getColumnOutline([ "", target.noteId, "missing" ])).toEqual([
|
||||
{ value: "", title: "board_view.inbox", icon: "bx bxs-inbox", count: 0 },
|
||||
// The note's own icon, as `FNote` gives it, class and all.
|
||||
{ value: target.noteId, title: "Alice", icon: "tn-icon bx bx-user", count: 0 },
|
||||
// A note the cache has not got: the value is all there is to go on.
|
||||
{ value: "missing", title: "missing", icon: DEFAULT_COLUMN_ICON, count: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -25,8 +25,9 @@ import {
|
||||
import { BoardColumnData, BoardViewData } from ".";
|
||||
import { currentCardTemplate, DEFAULT_CARD_TEMPLATES } from "./card_templates";
|
||||
import {
|
||||
type BoardStatusDefinition, canStoreColumnsInDefinition, DEFAULT_COLUMN_ICON,
|
||||
DEFAULT_GROUP_BY, INBOX_COLUMN, INBOX_COLUMN_ICON
|
||||
type BoardStatusDefinition, canStoreColumnsInDefinition, COLUMN_WIDTH_LABEL, type ColumnWidth,
|
||||
DEFAULT_COLUMN_ICON, DEFAULT_COLUMN_WIDTH, DEFAULT_GROUP_BY, INBOX_COLUMN, INBOX_COLUMN_ICON,
|
||||
parseColumnWidth
|
||||
} from "./columns";
|
||||
import { readColumns, writeColumns } from "./column_storage";
|
||||
import { ColumnItem, ColumnMap } from "./data";
|
||||
@ -561,15 +562,59 @@ export default class BoardApi {
|
||||
* Most columns are identified by the value their cards carry, so renaming writes that value
|
||||
* to every card in the column. The inbox has no value, so it stores a display name instead and
|
||||
* its cards are left untouched.
|
||||
*
|
||||
* @returns `false` when nothing was written, which keeps the caller's editor open: the name
|
||||
* is blank, or another column already uses it and renaming would merge the two.
|
||||
*/
|
||||
async setColumnTitle(column: string, title: string) {
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
setColumnTitle(column: string, title: string): false | void | Promise<void> {
|
||||
const name = title.trim();
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isColumnNameTaken(name, column)) {
|
||||
toast.showMessage(t("board_view.column-name-taken", { column: name }), undefined,
|
||||
"bx bx-duplicate");
|
||||
return false;
|
||||
}
|
||||
|
||||
return column === INBOX_COLUMN
|
||||
? this.updateColumn(column, { displayName: title.trim() })
|
||||
: this.renameColumn(column, title);
|
||||
? this.updateColumn(column, { displayName: name })
|
||||
: this.renameColumn(column, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a column other than `except` already uses a name.
|
||||
*
|
||||
* Compares titles as well as values, since the inbox is named by `displayName`, and covers the
|
||||
* stored columns so that an empty one counts.
|
||||
*/
|
||||
private isColumnNameTaken(name: string, except: string) {
|
||||
const values = new Set([ ...this.columns, ...this.storedColumns.map(col => col.value) ]);
|
||||
for (const value of values) {
|
||||
if (value !== except && (value === name || this.getColumnTitle(value) === name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each column is called, the icon it shows and how many cards it holds, which the right
|
||||
* pane's outline is drawn from.
|
||||
*
|
||||
* A relation board keys its columns by note id, so each one is named from the note's title.
|
||||
*/
|
||||
getColumnOutline(columns: string[]) {
|
||||
return columns.map(column => ({
|
||||
value: column,
|
||||
title: this.isRelationMode && column !== INBOX_COLUMN
|
||||
? froca.getNoteFromCache(column)?.title ?? column
|
||||
: this.getColumnTitle(column),
|
||||
icon: this.getColumnIcon(column) ?? DEFAULT_COLUMN_ICON,
|
||||
count: this.byColumn?.get(column)?.length ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -579,7 +624,9 @@ export default class BoardApi {
|
||||
* `NoteLink` puts in the heading — and `setColumnIcon` is not offered there.
|
||||
*/
|
||||
getColumnIcon(column: string) {
|
||||
if (this.isRelationMode) {
|
||||
// The inbox stands for the cards carrying no value at all, so there is no note behind it
|
||||
// even on a relation board, where every other column is one.
|
||||
if (this.isRelationMode && column !== INBOX_COLUMN) {
|
||||
return froca.getNoteFromCache(column)?.getIcon();
|
||||
}
|
||||
|
||||
@ -629,6 +676,33 @@ export default class BoardApi {
|
||||
await attributes.setBooleanWithInheritance(this.parentNote, "includeArchived", shown);
|
||||
}
|
||||
|
||||
/** How wide the board draws its columns, which the board turns into a class of its own. */
|
||||
get columnWidth() {
|
||||
return parseColumnWidth(this.parentNote?.getLabelValue(COLUMN_WIDTH_LABEL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets how wide the columns are drawn.
|
||||
*
|
||||
* Picking the default removes the label, to keep the note tidy. Where a template or a parent
|
||||
* sets the label, the default is written out instead: `removeOwnedLabelByName` would leave the
|
||||
* inherited value in force.
|
||||
*/
|
||||
async setColumnWidth(width: ColumnWidth) {
|
||||
const note = this.parentNote;
|
||||
if (!note) return;
|
||||
|
||||
const inherited = note.getAttributes("label", COLUMN_WIDTH_LABEL)
|
||||
.find(attribute => attribute.noteId !== note.noteId);
|
||||
if (width === DEFAULT_COLUMN_WIDTH
|
||||
&& parseColumnWidth(inherited?.value) === DEFAULT_COLUMN_WIDTH) {
|
||||
await attributes.removeOwnedLabelByName(note, COLUMN_WIDTH_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
await attributes.setLabel(note.noteId, COLUMN_WIDTH_LABEL, width);
|
||||
}
|
||||
|
||||
/** The note limit set for a column, absent if disabled. */
|
||||
getColumnLimit(column: string) {
|
||||
return this.storedColumns.find(col => col.value === column)?.limit;
|
||||
|
||||
@ -7,6 +7,12 @@ import {
|
||||
type BoardDragCallbacks, type DraggedCard, type DropPosition, useBoardDrag
|
||||
} from "./board_drag";
|
||||
|
||||
// i18next is never initialised under test, so the overlay would be left saying nothing at all.
|
||||
vi.mock("../../../services/i18n", () => ({
|
||||
t: (key: string) => key,
|
||||
translationsInitializedPromise: Promise.resolve()
|
||||
}));
|
||||
|
||||
describe("useBoardDrag, carrying a card", () => {
|
||||
let container: HTMLElement | undefined;
|
||||
let board: HTMLElement;
|
||||
@ -722,10 +728,197 @@ describe("useBoardDrag, carrying a card", () => {
|
||||
* Two 100px columns, 200 apart, each card 50 tall. The first holds two cards, the second one.
|
||||
* happy-dom lays nothing out, so every box is declared.
|
||||
*/
|
||||
function setup({ disabled = false, carried }: {
|
||||
describe("carrying a card to one end of a column", () => {
|
||||
/** Ten cards in the first column, scrolled so that neither end of it is on screen. */
|
||||
const tall = {
|
||||
layout: [ Array.from({ length: 10 }, (_, index) => `c${index + 1}`), [ "n3" ] ],
|
||||
scrollTop: 120
|
||||
};
|
||||
|
||||
/** Taken 25 below its own top edge, so the card and the pointer are never in one place. */
|
||||
function takeHold() {
|
||||
press(card("c3"), 50, 65);
|
||||
}
|
||||
|
||||
/**
|
||||
* The column runs on above and below what it shows, so the places the two ends name are
|
||||
* nowhere near the ones the cards on screen give.
|
||||
*/
|
||||
it("places the card at the head of the column, and at its foot", () => {
|
||||
setup(tall);
|
||||
|
||||
takeHold();
|
||||
move(50, 225);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "To Do", index: 5 });
|
||||
|
||||
// Clear above the heading, which is the head of the column whatever it is scrolled to.
|
||||
move(50, -40);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "To Do", index: 0 });
|
||||
|
||||
// Clear below the button that adds a card, which is its foot.
|
||||
move(50, 440);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "To Do", index: 10 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Both are where a card is held to walk the column along, which is how a reader reaches a
|
||||
* place in the middle of a long one.
|
||||
*/
|
||||
it("leaves the heading and the button that adds a card out of both ends", () => {
|
||||
setup(tall);
|
||||
const showing = () => !!preview()?.classList.contains("showing-drop-hint");
|
||||
const area = board.querySelector<HTMLElement>(".board-column-content");
|
||||
|
||||
takeHold();
|
||||
// On the heading, the card's own top edge standing above the column altogether.
|
||||
move(50, 20);
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
expect(showing()).toBe(false);
|
||||
// What the heading is for while a card is held over it: the column walks up under it,
|
||||
// and the card is placed against the cards that brings into view.
|
||||
expect(area?.scrollTop).toBeLessThan(120);
|
||||
|
||||
// On the button at the foot, and in the band just past it.
|
||||
move(50, 360);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(showing()).toBe(false);
|
||||
expect(calls.move.at(-1)?.position).not.toEqual({ column: "To Do", index: 10 });
|
||||
|
||||
move(50, 425);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(showing()).toBe(false);
|
||||
});
|
||||
|
||||
it("says on the copy being carried what letting go would do", () => {
|
||||
setup(tall);
|
||||
const hint = () => preview()?.querySelector<HTMLElement>(".board-drop-hint");
|
||||
|
||||
takeHold();
|
||||
move(50, -40);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(preview()?.classList.contains("showing-drop-hint")).toBe(true);
|
||||
expect(hint()?.textContent).toBe("board_view.drop-as-first-item");
|
||||
|
||||
move(50, 440);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(hint()?.textContent).toBe("board_view.drop-as-last-item");
|
||||
|
||||
// Back among the cards, where the card is placed against them and says nothing.
|
||||
move(50, 225);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(preview()?.classList.contains("showing-drop-hint")).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A card taller than the board is cut off by it, and a label in the middle of the card is
|
||||
* cut off with it. The board runs 0 to 600 here and the window reaches further, so a label
|
||||
* placed against the window would land below what the reader can see.
|
||||
*/
|
||||
it("lets the copy leave the board, but not past a sliver of itself", () => {
|
||||
setup(tall);
|
||||
const labelEl = () => preview()
|
||||
?.querySelector<HTMLElement>(".board-drop-hint span") as HTMLElement;
|
||||
const shift = () => Number(labelEl()?.style.transform.match(/-?[\d.]+/)?.[0]);
|
||||
const visible = () =>
|
||||
Number(preview()?.style.getPropertyValue("--board-drag-visible"));
|
||||
/** How far down the page the copy has been moved, off its own transform. */
|
||||
const moved = () =>
|
||||
Number(preview()?.style.transform.match(/translate3d\([^,]+,\s*(-?[\d.]+)px/)?.[1]);
|
||||
const showing = parseFloat(getComputedStyle(card("c3")).fontSize) * 1.5;
|
||||
|
||||
// A card of its own height, carried above the board's head. Negative here: in the app
|
||||
// the board's head sits below the window's.
|
||||
takeHold();
|
||||
move(50, -60);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
// Drawn 45 tall about a middle that starts 40 below the page's top, so this is where
|
||||
// its foot stands: a sliver of it inside the board's head at 0.
|
||||
expect(40 + moved() + 50 / 2 + 45 / 2).toBeCloseTo(showing, 5);
|
||||
release(50, -60);
|
||||
|
||||
// A card taller than the board, carried past the foot but still short of leaving it.
|
||||
place(card("c3"), 0, 40, 100, 2000);
|
||||
press(card("c3"), 50, 65);
|
||||
move(50, 440);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
const head = () => 40 + moved() + 2000 / 2 - 1800 / 2;
|
||||
expect(head()).toBeCloseTo(515, 5);
|
||||
expect(visible()).toBeCloseTo(0.034, 3);
|
||||
|
||||
// Carried on out, it stops with the same sliver against the board's foot.
|
||||
move(50, 1400);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
const stood = moved();
|
||||
expect(head()).toBeCloseTo(600 - showing, 5);
|
||||
// Against the edge, where the fade has run its course.
|
||||
expect(visible()).toBe(0);
|
||||
|
||||
// And stands still however far past the board the pointer goes.
|
||||
move(50, 1500);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(moved()).toBe(stood);
|
||||
|
||||
// The label keeps to the middle of what the board shows of the copy.
|
||||
const label = 40 + moved() + 2000 / 2 + shift() * 0.9;
|
||||
expect(label).toBeGreaterThan(0);
|
||||
expect(label).toBeLessThan(600);
|
||||
});
|
||||
|
||||
it("offers both ends of a collapsed column, which draws none of its cards", () => {
|
||||
setup();
|
||||
// Collapsed before the board opened, so its cards were never drawn: the strip is its
|
||||
// heading alone, and how many it holds is read off the column itself.
|
||||
const strip = board.querySelectorAll<HTMLElement>(".board-column")[1];
|
||||
strip.classList.add("collapsed");
|
||||
strip.dataset.count = "7";
|
||||
strip.querySelector(".board-column-content")?.remove();
|
||||
strip.querySelector(".board-new-item")?.remove();
|
||||
place(strip, 200, 0, 40, 400);
|
||||
place(strip.querySelector("h3") as HTMLElement, 200, 0, 40, 400);
|
||||
|
||||
press(card("n1"), 10, 10);
|
||||
move(210, 440);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "Doing", index: 7 });
|
||||
|
||||
move(210, -40);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "Doing", index: 0 });
|
||||
|
||||
// Over the strip itself it goes to the front, as it did before either end was offered.
|
||||
move(210, 200);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "Doing", index: 0 });
|
||||
release(210, 200);
|
||||
});
|
||||
|
||||
/** A sorted column places what it is given, so neither of its ends is on offer. */
|
||||
it("offers no end of a column that orders its own cards", () => {
|
||||
setup(tall);
|
||||
board.querySelectorAll<HTMLElement>(".board-column")[0].dataset.sorted = "true";
|
||||
|
||||
takeHold();
|
||||
move(50, -40);
|
||||
act(() => { vi.advanceTimersByTime(20); });
|
||||
|
||||
expect(preview()?.classList.contains("showing-drop-hint")).toBe(false);
|
||||
expect(calls.move.at(-1)?.position).toEqual({ column: "To Do", index: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function setup({
|
||||
disabled = false, carried, layout = [ [ "n1", "n2" ], [ "n3" ] ], scrollTop = 0
|
||||
}: {
|
||||
disabled?: boolean,
|
||||
/** The cards a press answers with, for the tests about carrying a selection. */
|
||||
carried?: string[]
|
||||
carried?: string[],
|
||||
/** What each column holds, for a test that needs more cards than a column can show. */
|
||||
layout?: string[][],
|
||||
/** How far each column is scrolled, its cards standing that much higher on screen. */
|
||||
scrollTop?: number
|
||||
} = {}) {
|
||||
const mountPoint = document.createElement("div");
|
||||
container = mountPoint;
|
||||
@ -752,11 +945,11 @@ describe("useBoardDrag, carrying a card", () => {
|
||||
board.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "board-drag-layer"
|
||||
}));
|
||||
place(board, 0, 0, 500, 400);
|
||||
place(board, 0, 0, 500, 600);
|
||||
// Writable: the board walks itself along while something is held at its edge.
|
||||
Object.defineProperty(board, "scrollLeft", { value: 0, configurable: true, writable: true });
|
||||
|
||||
for (const [ index, cards ] of [ [ "n1", "n2" ], [ "n3" ] ].entries()) {
|
||||
for (const [ index, cards ] of layout.entries()) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "board-column";
|
||||
column.dataset.column = [ "To Do", "Doing" ][index];
|
||||
@ -775,9 +968,9 @@ describe("useBoardDrag, carrying a card", () => {
|
||||
const area = document.createElement("div");
|
||||
area.className = "board-column-content";
|
||||
column.appendChild(area);
|
||||
place(area, index * 200, 40, 100, 360);
|
||||
place(area, index * 200, 40, 100, 300);
|
||||
Object.defineProperty(area, "scrollTop", {
|
||||
value: 0, configurable: true, writable: true
|
||||
value: scrollTop, configurable: true, writable: true
|
||||
});
|
||||
|
||||
for (const [ position, noteId ] of cards.entries()) {
|
||||
@ -788,8 +981,14 @@ describe("useBoardDrag, carrying a card", () => {
|
||||
className: "edit-icon"
|
||||
}));
|
||||
area.appendChild(note);
|
||||
place(note, index * 200, 40 + position * 60, 100, 50);
|
||||
place(note, index * 200, 40 + position * 60 - scrollTop, 100, 50);
|
||||
}
|
||||
|
||||
// The button that adds a card, which stands at the foot of every open column.
|
||||
const adder = document.createElement("div");
|
||||
adder.className = "board-new-item";
|
||||
column.appendChild(adder);
|
||||
place(adder, index * 200, 340, 100, 60);
|
||||
}
|
||||
|
||||
// Attached from an effect, which Preact defers past the render.
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { RefObject } from "preact";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../../services/i18n";
|
||||
import { useTrackedElement } from "../../react/hooks";
|
||||
import {
|
||||
type CardBox, cardInsertionIndex, columnAt, type ColumnBox, columnCovers, columnInsertionIndex
|
||||
type CardBox, cardInsertionIndex, columnAt, type ColumnBox, columnCovers, type ColumnEnd,
|
||||
columnEndAt, columnInsertionIndex
|
||||
} from "./drag_geometry";
|
||||
import {
|
||||
type BoardMeasurement, measureBoard, placeInModel, toAreaY, toBoardX
|
||||
@ -23,6 +25,9 @@ const TOUCH_TOLERANCE = 8;
|
||||
/** How much a carried card shrinks. Written with the movement, a class could not add to it. */
|
||||
const DRAG_SCALE = 0.9;
|
||||
|
||||
/** How much of a copy put against the board's edge is left showing, in em of the card. */
|
||||
const EDGE_SHOWING = 1.5;
|
||||
|
||||
/**
|
||||
* How many cards a carried selection is drawn as, however many are on the move: the one holding the
|
||||
* count and the two behind it. `index.css` draws the ones behind from `data-layers`.
|
||||
@ -199,7 +204,10 @@ export function useBoardDrag(
|
||||
});
|
||||
}
|
||||
held.preview = lifted.preview;
|
||||
held.hint = lifted.hint;
|
||||
held.grab = lifted.grab;
|
||||
held.ownHue = lifted.ownHue;
|
||||
held.showing = lifted.showing;
|
||||
container.classList.add("board-dragging");
|
||||
setDragging(true);
|
||||
|
||||
@ -237,21 +245,29 @@ export function useBoardDrag(
|
||||
|
||||
const column = columnAt(measurement.columns, x);
|
||||
const area = column && measurement.areas.get(column.value);
|
||||
// A pointer past either end places the card at that end whatever the column is
|
||||
// scrolled to, which saves dragging it the length of a long column. Read from the
|
||||
// pointer rather than the card's top edge, as auto-scrolling is: both ends lie outside
|
||||
// the column, and a card held below its top edge cannot reach past the button.
|
||||
const end = column ? columnEndAt(column, held.lastY) : undefined;
|
||||
const edges: ScrollTarget[] = [ { element: container, axis: "x" } ];
|
||||
if (area) {
|
||||
// No vertical auto-scroll while an end is the target: the index is already decided,
|
||||
// and scrolling would suggest the card is being placed where it passes.
|
||||
if (area && !end) {
|
||||
edges.push({ element: area, axis: "y" });
|
||||
}
|
||||
scroller.update(edges, held.lastX, held.lastY);
|
||||
|
||||
markEnd(held, end, column);
|
||||
|
||||
// A collapsed column draws no card area and holds no cards on screen, so a card carried
|
||||
// over it goes to the front of whatever it holds.
|
||||
// A collapsed column draws no card area, so a card carried over it goes to the front.
|
||||
// Either end can still be asked for, placing a card there without opening the column.
|
||||
const position = column
|
||||
? {
|
||||
column: column.value,
|
||||
index: area
|
||||
index: endIndex(end, column) ?? (area
|
||||
? placeAt(area, toAreaY(area, topY), held.card, column)
|
||||
: 0
|
||||
: 0)
|
||||
}
|
||||
: null;
|
||||
// Standing on the column itself, which for a collapsed one is its heading alone: the
|
||||
@ -274,7 +290,86 @@ export function useBoardDrag(
|
||||
report(held, position);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the overlay on the carried copy, naming the end of the column the card would go to,
|
||||
* and hides it again for a position among the cards.
|
||||
*
|
||||
* Paints it in the card's own colour, or in the hue of `over` where the card has none.
|
||||
*
|
||||
* Shifts the label towards the part of the copy still inside the board, so that a card
|
||||
* taller than the board keeps it visible, and fades the copy by how much of it the board
|
||||
* no longer shows. `grab` and `viewport` were both measured when the card was picked up, so
|
||||
* this reads no layout.
|
||||
*/
|
||||
const markEnd = (held: Gesture, end: ColumnEnd | undefined, over?: ColumnBox) => {
|
||||
const hint = held.hint;
|
||||
const grab = held.grab;
|
||||
const view = held.measurement?.viewport;
|
||||
if (!hint || !grab || !view) return;
|
||||
|
||||
const label = hint.firstElementChild;
|
||||
if (end !== held.end) {
|
||||
held.end = end;
|
||||
held.preview?.classList.toggle("showing-drop-hint", !!end);
|
||||
if (end && label instanceof HTMLElement) {
|
||||
label.textContent = t(end === "first"
|
||||
? "board_view.drop-as-first-item"
|
||||
: "board_view.drop-as-last-item");
|
||||
// Read once, as the label is written: every move that follows works from
|
||||
// this rather than reading the page again.
|
||||
held.labelHeight = label.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (!end || !(label instanceof HTMLElement)) return;
|
||||
|
||||
const hue = held.ownHue || over?.hue;
|
||||
held.preview?.classList.toggle("hint-tinted", !!hue);
|
||||
if (hue) {
|
||||
held.preview?.style.setProperty("--board-drop-hint-hue", hue);
|
||||
}
|
||||
|
||||
const middle = held.lastY - grab.y + grab.height / 2 + holdBack(held);
|
||||
const drawn = grab.height * DRAG_SCALE / 2;
|
||||
const top = Math.max(middle - drawn, view.top);
|
||||
const bottom = Math.min(middle + drawn, view.bottom);
|
||||
// How much of the copy is in view, measured against the least it can show rather than
|
||||
// against nothing: 1 while wholly in view, 0 once it is against the edge.
|
||||
const height = drawn * 2;
|
||||
const least = height > 0 ? Math.min(1, (held.showing ?? 0) / height) : 1;
|
||||
const shown = height > 0 ? Math.max(0, Math.min(1, (bottom - top) / height)) : 1;
|
||||
const visible = least < 1 ? Math.max(0, (shown - least) / (1 - least)) : 1;
|
||||
held.preview?.style.setProperty("--board-drag-visible", visible.toFixed(3));
|
||||
|
||||
// Outside the board altogether: there is no part of the copy to keep the label in.
|
||||
const shift = bottom > top ? ((top + bottom) / 2 - middle) / DRAG_SCALE : 0;
|
||||
// The label stands on the card, so it goes no further than the card's own edges.
|
||||
const room = Math.max(0, (grab.height - (held.labelHeight ?? 0)) / 2);
|
||||
label.style.transform =
|
||||
`translateY(${Math.max(-room, Math.min(room, shift))}px)`;
|
||||
};
|
||||
|
||||
/** Says where the card would land, and whether it has come to rest on that column. */
|
||||
/**
|
||||
* How far back from the pointer the copy is drawn while an end is being asked for, so that
|
||||
* it leaves the board only until {@link EDGE_SHOWING} of it is still in view. Returns 0
|
||||
* while no end is being asked for, where the copy follows the pointer exactly.
|
||||
*/
|
||||
const holdBack = (held: Gesture) => {
|
||||
const grab = held.grab;
|
||||
const view = held.measurement?.viewport;
|
||||
if (!held.end || !grab || !view) return 0;
|
||||
|
||||
const middle = held.lastY - grab.y + grab.height / 2;
|
||||
const drawn = grab.height * DRAG_SCALE / 2;
|
||||
const inset = held.showing ?? 0;
|
||||
const wanted = held.end === "first"
|
||||
? Math.max(middle, view.top + inset - drawn)
|
||||
: Math.min(middle, view.bottom - inset + drawn);
|
||||
|
||||
return wanted - middle;
|
||||
};
|
||||
|
||||
const report = (held: Gesture, next?: DropPosition | null) => {
|
||||
if (held.kind !== "card") return;
|
||||
|
||||
@ -358,11 +453,13 @@ export function useBoardDrag(
|
||||
held.frame = undefined;
|
||||
if (!gesture.current || !held.preview) return;
|
||||
|
||||
// Resolved first: where the copy stands depends on the end being asked for.
|
||||
resolve(held);
|
||||
|
||||
const dx = held.lastX - held.startX;
|
||||
const dy = held.lastY - held.startY;
|
||||
const dy = held.lastY - held.startY + holdBack(held);
|
||||
const scale = held.kind === "card" ? ` scale(${DRAG_SCALE})` : "";
|
||||
held.preview.style.transform = `translate3d(${dx}px, ${dy}px, 0)${scale}`;
|
||||
resolve(held);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -589,6 +686,20 @@ function placeIn(cards: CardBox[], y: number, card: DraggedCard, column: string)
|
||||
return index >= card.index ? index + 1 : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* The place one end of a column names, or nothing where neither end is being asked for.
|
||||
*
|
||||
* The cards were counted with the carried one among them, which is the list a move is expressed
|
||||
* in, so the last place is that count rather than one less.
|
||||
*/
|
||||
function endIndex(end: ColumnEnd | undefined, column: ColumnBox) {
|
||||
if (!end) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return end === "first" ? 0 : column.count;
|
||||
}
|
||||
|
||||
/** Asks an element for its menu the way a right click does, at the place the tap landed. */
|
||||
function askForMenu(element: HTMLElement, clientX: number, clientY: number) {
|
||||
element.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX, clientY }));
|
||||
@ -691,6 +802,17 @@ function lift(held: Gesture, container: HTMLElement) {
|
||||
preview.querySelector(".edit-icon")?.remove();
|
||||
preview.querySelector(".board-new-item")?.remove();
|
||||
|
||||
// The overlay saying the card would go to one end of a column, hidden until it would. Built
|
||||
// with the copy so a move only turns it on, and only for a card: a column is not placed this
|
||||
// way.
|
||||
let hint: HTMLElement | undefined;
|
||||
if (held.kind === "card") {
|
||||
hint = document.createElement("div");
|
||||
hint.className = "board-drop-hint";
|
||||
hint.appendChild(document.createElement("span"));
|
||||
preview.appendChild(hint);
|
||||
}
|
||||
|
||||
// The copy is carried in the drag layer rather than in the column it came from, where the rule
|
||||
// that tints a card no longer reaches it, so the column's hue is put on the copy itself.
|
||||
const hue = held.element.closest<HTMLElement>(".board-column.with-hue")
|
||||
@ -700,6 +822,14 @@ function lift(held: Gesture, container: HTMLElement) {
|
||||
preview.style.setProperty("--board-column-custom-hue", hue);
|
||||
}
|
||||
|
||||
// The card's own colour and its em, read here rather than on every move: both cost a page
|
||||
// read.
|
||||
const style = getComputedStyle(held.element);
|
||||
const ownHue = held.element.classList.contains("with-hue")
|
||||
? style.getPropertyValue("--custom-color-hue").trim()
|
||||
: undefined;
|
||||
const showing = (parseFloat(style.fontSize) || 0) * EDGE_SHOWING;
|
||||
|
||||
for (const [ property, value ] of Object.entries({
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
@ -717,6 +847,9 @@ function lift(held: Gesture, container: HTMLElement) {
|
||||
held.element.style.display = "none";
|
||||
return {
|
||||
preview,
|
||||
hint,
|
||||
ownHue,
|
||||
showing,
|
||||
size: { width: rect.width, height: rect.height },
|
||||
// Where inside it the reader took hold, so the middle can be found from the pointer.
|
||||
grab: {
|
||||
@ -782,6 +915,16 @@ type Gesture = (CardSubject | ColumnSubject) & {
|
||||
active: boolean;
|
||||
/** The copy that follows the pointer, what it stands for staying where it is. */
|
||||
preview?: HTMLElement;
|
||||
/** The overlay on the copy saying the card would go to one end of a column. */
|
||||
hint?: HTMLElement;
|
||||
/** Which end that is, so the overlay is written only as it changes. */
|
||||
end?: ColumnEnd;
|
||||
/** The carried card's own colour as a hue, which the overlay takes before a column's. */
|
||||
ownHue?: string;
|
||||
/** What the label inside it measures, read when it is written rather than on every move. */
|
||||
labelHeight?: number;
|
||||
/** How much of the copy is left in view once it stands against the board's edge. */
|
||||
showing?: number;
|
||||
/** Where the press landed inside it, and how big it is, for finding its middle. */
|
||||
grab?: { x: number, y: number, width: number, height: number };
|
||||
measurement?: BoardMeasurement;
|
||||
|
||||
@ -596,6 +596,11 @@ export default function Column({
|
||||
return (
|
||||
<div
|
||||
data-column={column}
|
||||
// Read by the drag, which offers neither end of a column that places its own cards.
|
||||
data-sorted={isSorted ? "true" : undefined}
|
||||
// Also read by the drag, for placing a card at the column's foot. On the column rather
|
||||
// than on the card area below, which a collapsed column does not draw at all.
|
||||
data-count={noteIds.length}
|
||||
className={clsx("board-column", {
|
||||
"drag-over": isDropTarget && (isSorted || draggedCard?.fromColumn !== column),
|
||||
// The class the themes key a hue off, worn here as anywhere else that carries one.
|
||||
|
||||
@ -8,7 +8,9 @@ import { buildNote } from "../../../test/easy-froca";
|
||||
import {
|
||||
BOARD_TEMPLATE_ID,
|
||||
canStoreColumnsInDefinition,
|
||||
columnWidthClass,
|
||||
getStatusDefinition,
|
||||
parseColumnWidth,
|
||||
resolveBoardColumns
|
||||
} from "./columns";
|
||||
|
||||
@ -205,3 +207,32 @@ describe("the inbox column", () => {
|
||||
.toEqual([ "", "To Do" ]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseColumnWidth", () => {
|
||||
/** The label is the user's to write by hand, so it can name a width the board does not have. */
|
||||
it("reads the three widths and falls back to the narrow default", () => {
|
||||
expect(parseColumnWidth("narrow")).toBe("narrow");
|
||||
expect(parseColumnWidth("medium")).toBe("medium");
|
||||
expect(parseColumnWidth("wide")).toBe("wide");
|
||||
|
||||
expect(parseColumnWidth("enormous")).toBe("narrow");
|
||||
expect(parseColumnWidth("")).toBe("narrow");
|
||||
expect(parseColumnWidth(null)).toBe("narrow");
|
||||
expect(parseColumnWidth(undefined)).toBe("narrow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnWidthClass", () => {
|
||||
it("names a class only for a width the board names itself", () => {
|
||||
expect(columnWidthClass("narrow")).toBe("board-narrow-columns");
|
||||
expect(columnWidthClass("medium")).toBe("board-medium-columns");
|
||||
expect(columnWidthClass("wide")).toBe("board-wide-columns");
|
||||
|
||||
// Nothing to wear, so `--board-column-width` keeps the value it inherits. A theme setting
|
||||
// that variable is what this leaves room for.
|
||||
expect(columnWidthClass("enormous")).toBeUndefined();
|
||||
expect(columnWidthClass("")).toBeUndefined();
|
||||
expect(columnWidthClass(null)).toBeUndefined();
|
||||
expect(columnWidthClass(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -32,6 +32,37 @@ export const INBOX_COLUMN = "";
|
||||
/** Default icon for the inbox column, used instead of the standard one until another is picked. */
|
||||
export const INBOX_COLUMN_ICON = "bx bxs-inbox";
|
||||
|
||||
/** The label naming how wide the board draws its columns. */
|
||||
export const COLUMN_WIDTH_LABEL = "boardCardWidth";
|
||||
|
||||
/** The widths the board offers, in the order the properties dialog lists them. */
|
||||
export const COLUMN_WIDTHS = [ "narrow", "medium", "wide" ] as const;
|
||||
|
||||
export type ColumnWidth = typeof COLUMN_WIDTHS[number];
|
||||
|
||||
/** The width a board with no label of its own draws its columns at. */
|
||||
export const DEFAULT_COLUMN_WIDTH: ColumnWidth = "narrow";
|
||||
|
||||
/**
|
||||
* Reads a stored width, falling back to the default for anything the board does not offer. The
|
||||
* label is the user's to edit by hand, so it can name a width that does not exist.
|
||||
*/
|
||||
export function parseColumnWidth(value: string | null | undefined) {
|
||||
return COLUMN_WIDTHS.find(width => width === value) ?? DEFAULT_COLUMN_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* The class a board wears for the width it names, or nothing where it names none.
|
||||
*
|
||||
* A board that names no width wears no class, so `--board-column-width` keeps whatever value it
|
||||
* inherits: the default in this stylesheet, or a theme's own if one sets it.
|
||||
*/
|
||||
export function columnWidthClass(value: string | null | undefined) {
|
||||
const width = COLUMN_WIDTHS.find(candidate => candidate === value);
|
||||
|
||||
return width ? `board-${width}-columns` : undefined;
|
||||
}
|
||||
|
||||
export interface BoardStatusDefinition {
|
||||
/** The definition attribute, wherever it is owned. */
|
||||
attribute: FAttribute;
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type CardBox, cardInsertionIndex, columnAt, type ColumnBox, columnCovers,
|
||||
type CardBox, cardInsertionIndex, columnAt, type ColumnBox, columnCovers, columnEndAt,
|
||||
columnInsertionIndex, columnStandsAside, movesColumn
|
||||
} from "./drag_geometry";
|
||||
|
||||
/** Three 100px columns with a 20px gap, standing 400 tall from the top of the page. */
|
||||
/**
|
||||
* Three 100px columns with a 20px gap, standing 400 tall from the top of the page. The heading
|
||||
* begins at the top of each and the button that adds a card ends at its foot.
|
||||
*/
|
||||
function columns(cards: Record<string, CardBox[]> = {}): ColumnBox[] {
|
||||
return [ "To Do", "Doing", "Done" ].map((value, index) => ({
|
||||
value,
|
||||
@ -14,7 +17,11 @@ function columns(cards: Record<string, CardBox[]> = {}): ColumnBox[] {
|
||||
top: 0,
|
||||
origin: 0,
|
||||
height: 400,
|
||||
cards: cards[value] ?? []
|
||||
headStart: 0,
|
||||
footEnd: 400,
|
||||
sorted: false,
|
||||
cards: cards[value] ?? [],
|
||||
count: (cards[value] ?? []).length
|
||||
}));
|
||||
}
|
||||
|
||||
@ -80,7 +87,8 @@ describe("columnCovers", () => {
|
||||
*/
|
||||
it("tells the column from the empty space below a short one", () => {
|
||||
const strip: ColumnBox = {
|
||||
value: "Parked", left: 0, width: 36, top: 0, height: 90, origin: 0, cards: []
|
||||
value: "Parked", left: 0, width: 36, top: 0, height: 90, origin: 0,
|
||||
headStart: 90, footEnd: 90, sorted: false, cards: [], count: 0
|
||||
};
|
||||
|
||||
expect(columnCovers(strip, 18, 0)).toBe(true);
|
||||
@ -203,3 +211,45 @@ describe("columnStandsAside", () => {
|
||||
.toEqual([ 0, 0, 0, -266, -266, -266 ]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("columnEndAt", () => {
|
||||
/**
|
||||
* The two ends stand 30px clear of the column, the heading and the add button being where a
|
||||
* card is held to walk the column along rather than to place it.
|
||||
*/
|
||||
it("answers for what stands 30px above the heading and below the add button", () => {
|
||||
const [ column ] = columns();
|
||||
|
||||
expect(columnEndAt(column, -31)).toBe("first");
|
||||
expect(columnEndAt(column, -200)).toBe("first");
|
||||
|
||||
// The heading itself, the button at the foot, and the band past each of them.
|
||||
expect(columnEndAt(column, -30)).toBeUndefined();
|
||||
expect(columnEndAt(column, 0)).toBeUndefined();
|
||||
expect(columnEndAt(column, 200)).toBeUndefined();
|
||||
expect(columnEndAt(column, 400)).toBeUndefined();
|
||||
expect(columnEndAt(column, 429)).toBeUndefined();
|
||||
|
||||
expect(columnEndAt(column, 430)).toBe("last");
|
||||
expect(columnEndAt(column, 900)).toBe("last");
|
||||
});
|
||||
|
||||
/** A sorted column places what it is given, so neither of its ends is the reader's to pick. */
|
||||
it("offers neither end of a column that orders its own cards", () => {
|
||||
const [ column ] = columns();
|
||||
const sorted = { ...column, sorted: true };
|
||||
|
||||
expect(columnEndAt(sorted, 0)).toBeUndefined();
|
||||
expect(columnEndAt(sorted, 900)).toBeUndefined();
|
||||
});
|
||||
|
||||
/** A collapsed column is a heading and nothing else, and so is neither end of itself. */
|
||||
it("offers neither end of a column with no room between them", () => {
|
||||
const [ column ] = columns();
|
||||
const strip = { ...column, headStart: 40, footEnd: 40 };
|
||||
|
||||
expect(columnEndAt(strip, 0)).toBeUndefined();
|
||||
expect(columnEndAt(strip, 40)).toBeUndefined();
|
||||
expect(columnEndAt(strip, 900)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -35,11 +35,64 @@ export interface ColumnBox {
|
||||
* boundary falls into the slot below the one the gap is drawn at.
|
||||
*/
|
||||
origin: number;
|
||||
/**
|
||||
* Where the heading begins and where the button that adds a card ends, down the page. A pointer
|
||||
* past either one places the card at that end of the column. Neither the heading nor the button
|
||||
* counts as past: over them the column auto-scrolls instead, which is how a position in the
|
||||
* middle is reached. These hold for the length of a drag for the same reason {@link top} does.
|
||||
*/
|
||||
headStart: number;
|
||||
footEnd: number;
|
||||
/** Whether the column sorts its own cards, in which case the drop position is not chosen. */
|
||||
sorted: boolean;
|
||||
/** The column's own colour as a hue, or nothing where it has none. */
|
||||
hue?: string;
|
||||
/**
|
||||
* The cards as drawn, in order, the dragged one included. Counting it keeps the index in the
|
||||
* same terms as the list the board holds, which is what a move is expressed in.
|
||||
*/
|
||||
cards: CardBox[];
|
||||
/**
|
||||
* How many cards the column holds, the dragged one included. Read off the column rather than
|
||||
* counted here: a collapsed one draws none of them, and a windowed one only a slice.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Which end of a column a card would be placed at. */
|
||||
export type ColumnEnd = "first" | "last";
|
||||
|
||||
/**
|
||||
* How far past the heading or the button the pointer must go for that end to count, in pixels.
|
||||
*
|
||||
* Between the two the column auto-scrolls, and a pointer that has only just left that band is
|
||||
* usually crossing it rather than aiming at an end.
|
||||
*/
|
||||
const END_OFFSET = 30;
|
||||
|
||||
/**
|
||||
* The end of a column a point lies past: above the heading, or below the button that adds a card,
|
||||
* by {@link END_OFFSET} in either case.
|
||||
*
|
||||
* A card dropped there goes to that end whatever the column is scrolled to, which saves dragging it
|
||||
* the length of a long column. Returns `undefined` for a sorted column, where the drop position is
|
||||
* not chosen.
|
||||
*
|
||||
* @param y the pointer, down the page. A position among the cards is read from the carried card's
|
||||
* top edge instead; both ends lie outside the column, which only the pointer reaches.
|
||||
*/
|
||||
export function columnEndAt(column: ColumnBox, y: number): ColumnEnd | undefined {
|
||||
// With no space between the two ends there is no middle, and every point would match one of
|
||||
// them.
|
||||
if (column.sorted || column.footEnd <= column.headStart) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (y < column.headStart - END_OFFSET) {
|
||||
return "first";
|
||||
}
|
||||
|
||||
return y >= column.footEnd + END_OFFSET ? "last" : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -35,6 +35,12 @@ function buildBoard({ scrollLeft = 200, areaScrollTop = 20, cardCounts = [ 2, 1
|
||||
// On screen the columns have already been scrolled 200px to the left.
|
||||
place(column, { left: 50 + index * 120 - scrollLeft, top: 0, width: 100, height: 400 });
|
||||
|
||||
// The column is padded, so neither its heading nor the button at its foot is flush with
|
||||
// it: the two ends are read from those rather than from the column's own box.
|
||||
const heading = document.createElement("h3");
|
||||
column.appendChild(heading);
|
||||
place(heading, { left: 0, top: 8, width: 100, height: 40 });
|
||||
|
||||
const area = document.createElement("div");
|
||||
area.className = "board-column-content";
|
||||
column.appendChild(area);
|
||||
@ -57,6 +63,13 @@ function buildBoard({ scrollLeft = 200, areaScrollTop = 20, cardCounts = [ 2, 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const column of container.querySelectorAll<HTMLElement>(".board-column")) {
|
||||
const adder = document.createElement("div");
|
||||
adder.className = "board-new-item";
|
||||
column.appendChild(adder);
|
||||
place(adder, { left: 0, top: 352, width: 100, height: 40 });
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@ -394,3 +407,39 @@ describe("measuring a windowed column", () => {
|
||||
expect(column.cards).toHaveLength(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a column offers a card carried past it", () => {
|
||||
it("reads the two ends from the heading and the button at the foot", () => {
|
||||
const [ first ] = measureBoard(buildBoard()).columns;
|
||||
|
||||
// The column's own box runs 0 to 400; these are its heading and its button.
|
||||
expect(first.headStart).toBe(8);
|
||||
expect(first.footEnd).toBe(392);
|
||||
expect(first.sorted).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a column that orders its own cards off the column itself", () => {
|
||||
const board = buildBoard();
|
||||
board.querySelectorAll<HTMLElement>(".board-column")[1].dataset.sorted = "true";
|
||||
|
||||
expect(measureBoard(board).columns.map((column) => column.sorted)).toEqual([ false, true ]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the reader sees of the board", () => {
|
||||
it("is the note list holding it, and the board itself where it stands outside one", () => {
|
||||
const board = buildBoard();
|
||||
|
||||
expect(measureBoard(board).viewport).toEqual({ top: 0, bottom: 400 });
|
||||
|
||||
// The note list is what scrolls and clips, so a board inside one is seen through it.
|
||||
const list = document.createElement("div");
|
||||
list.className = "note-list-widget-content";
|
||||
document.body.appendChild(list);
|
||||
place(list, { left: 0, top: 100, width: 600, height: 250 });
|
||||
list.appendChild(board);
|
||||
|
||||
expect(measureBoard(board).viewport).toEqual({ top: 100, bottom: 350 });
|
||||
list.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,6 +7,12 @@ export interface BoardMeasurement {
|
||||
columns: ColumnBox[];
|
||||
/** Each column's card area, which a point has to be read against to place a card in it. */
|
||||
areas: Map<string, HTMLElement>;
|
||||
/**
|
||||
* What the reader sees of the board, down the page: the box of the note list holding it, or
|
||||
* the board itself where it stands outside one. The copy being carried is clipped to this
|
||||
* rather than to the window, so it is what says how much of the copy can be seen.
|
||||
*/
|
||||
viewport: { top: number, bottom: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -20,7 +26,10 @@ export interface BoardMeasurement {
|
||||
* pass over the whole board that nothing goes on to look at.
|
||||
*/
|
||||
export function measureBoard(container: HTMLElement, withCards = true): BoardMeasurement {
|
||||
const origin = container.getBoundingClientRect().left - container.scrollLeft;
|
||||
const box = container.getBoundingClientRect();
|
||||
const origin = box.left - container.scrollLeft;
|
||||
const clipped = (container.closest<HTMLElement>(".note-list-widget-content") ?? container)
|
||||
.getBoundingClientRect();
|
||||
const columns: ColumnBox[] = [];
|
||||
const areas = new Map<string, HTMLElement>();
|
||||
|
||||
@ -41,18 +50,31 @@ export function measureBoard(container: HTMLElement, withCards = true): BoardMea
|
||||
areas.set(value, area);
|
||||
}
|
||||
|
||||
const heading = element.querySelector<HTMLElement>(":scope > h3");
|
||||
const adder = element.querySelector<HTMLElement>(":scope > .board-new-item");
|
||||
|
||||
const cards = withCards ? measureCards(area) : [];
|
||||
const stated = Number(element.dataset.count);
|
||||
|
||||
columns.push({
|
||||
value,
|
||||
left: rect.left - origin,
|
||||
width: rect.width,
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
headStart: heading?.getBoundingClientRect().top ?? rect.top,
|
||||
footEnd: adder?.getBoundingClientRect().bottom ?? rect.bottom,
|
||||
sorted: element.dataset.sorted === "true",
|
||||
hue: element.classList.contains("with-hue")
|
||||
? element.style.getPropertyValue("--board-column-custom-hue")
|
||||
: undefined,
|
||||
origin: withCards ? contentOrigin(area) : 0,
|
||||
cards: withCards ? measureCards(area) : []
|
||||
cards,
|
||||
count: Number.isFinite(stated) ? stated : cards.length
|
||||
});
|
||||
}
|
||||
|
||||
return { columns, areas };
|
||||
return { columns, areas, viewport: { top: clipped.top, bottom: clipped.bottom } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -16,7 +16,12 @@
|
||||
/* Matches the height a normal header takes, so a collapsed column reads as one turned on
|
||||
its side. */
|
||||
--board-collapsed-column-width: 2.25em;
|
||||
--board-column-width: 275px;
|
||||
/* The three widths the board properties offer, and the one in use. Each is its own variable so
|
||||
a theme can change one width without taking over which of them a board picks. */
|
||||
--board-column-width-narrow: 275px;
|
||||
--board-column-width-medium: 325px;
|
||||
--board-column-width-wide: 400px;
|
||||
--board-column-width: var(--board-column-width-narrow);
|
||||
--board-column-border-width: 2px;
|
||||
--board-collapse-duration: 1s;
|
||||
--board-collapse-duration-quick: 0.3s;
|
||||
@ -27,12 +32,21 @@
|
||||
--board-item-hover-shadow: 2px 4px 8px rgba(0, 0, 0, 0.1);
|
||||
/* What a carried card casts, which the stack of a carried selection is drawn behind. */
|
||||
--board-drag-shadow: 4px 8px 16px rgba(0, 0, 0, 0.5);
|
||||
/* What a carried copy is drawn at, and what it fades to once it stands against the board's
|
||||
edge with only a sliver of itself in view. */
|
||||
--board-drag-opacity: 0.85;
|
||||
--board-drag-opacity-faded: 0.4;
|
||||
/* How far each card of a carried stack stands out from the one in front of it. */
|
||||
--board-drag-stack-offset: 4px;
|
||||
/* The ring a carried card wears, which the stack behind it starts clear of. */
|
||||
--board-drag-ring-width: 3px;
|
||||
/* The ring a picked-out card carries. */
|
||||
--board-selected-ring-color: var(--link-color);
|
||||
/* The overlay on a carried card while it would drop at one end of a column, painted in the hue
|
||||
the card itself is. */
|
||||
--board-drop-hint-background-saturation: 35%;
|
||||
--board-drop-hint-background-lightness: 25%;
|
||||
--board-drop-hint-color: #ffffff;
|
||||
/* Dims the board while a card is being named. Kept light: it draws the eye to the field rather
|
||||
than hiding what is behind it. */
|
||||
--board-edit-backdrop: rgba(0, 0, 0, 0.15);
|
||||
@ -110,6 +124,30 @@ body.mobile .board-view-container.board-dragging {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* While something is carried, everything the pointer crosses says so: the copy following it is out
|
||||
of hit testing, so the cursor comes from whatever lies beneath. It outweighs the cursors the
|
||||
heading, the cards and the buttons set for themselves, and the class goes on once a gesture
|
||||
rather than once a step, which is what makes a rule reaching this far affordable. */
|
||||
.board-view-container.board-dragging,
|
||||
.board-view-container.board-dragging * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/* Set from `#boardCardWidth`, and only while the board carries it: without one of these classes
|
||||
`--board-column-width` keeps the value it inherits, which a theme can set. The columns and
|
||||
everything measured against them read the width off that one variable. */
|
||||
.board-view.board-narrow-columns {
|
||||
--board-column-width: var(--board-column-width-narrow);
|
||||
}
|
||||
|
||||
.board-view.board-medium-columns {
|
||||
--board-column-width: var(--board-column-width-medium);
|
||||
}
|
||||
|
||||
.board-view.board-wide-columns {
|
||||
--board-column-width: var(--board-column-width-wide);
|
||||
}
|
||||
|
||||
.board-view-container .board-column {
|
||||
width: var(--board-column-width);
|
||||
flex-shrink: 0;
|
||||
@ -147,7 +185,6 @@ body.mobile .board-view-container .board-add-column {
|
||||
|
||||
.board-view-container .board-column.drag-over {
|
||||
border-color: var(--main-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-view-container .board-column h3 {
|
||||
@ -181,8 +218,11 @@ body.mobile .board-view-container .board-add-column {
|
||||
|
||||
.board-view-container .board-column h3 > .column-icon {
|
||||
/* Sized down from the note header's button, whose scale suits a toolbar rather than a heading. */
|
||||
--note-icon-size: 1.15em;
|
||||
--note-icon-container-padding-size: 0.15em;
|
||||
--note-icon-size: 1em;
|
||||
/* In pixels, as everywhere else the ring is set: it has to be whole for the glyph to sit centred
|
||||
in the disc, and the glyph alone follows the heading's font. The two together give the disc
|
||||
its width. */
|
||||
--note-icon-container-padding-size: 5px;
|
||||
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 0.4em;
|
||||
@ -416,15 +456,19 @@ body.mobile .board-view-container .board-add-column {
|
||||
|
||||
/*
|
||||
* The title and the icon before it, which stand where the editor's field and picker do: the icon at
|
||||
* `--card-icon-inset` from the card's edge and centred on the first line, the title at
|
||||
* `--card-title-inset` with the lines below it kept off the icon, as the field's own padding keeps
|
||||
* them.
|
||||
* `--card-icon-inset` from the card's edge, the title at `--card-title-inset` with the lines below
|
||||
* it kept off the icon, as the field's own padding keeps them.
|
||||
*
|
||||
* A hanging indent rather than two boxes beside each other: the icon shares the first line's box,
|
||||
* so it and the title rest on one baseline and rasterise on the same pixel row. Laid out as boxes
|
||||
* of their own they round to the grid apart, which put the icon a pixel off on some cards.
|
||||
*
|
||||
* The card's padding is already in front of both, so each is drawn back by it.
|
||||
*/
|
||||
.board-view-container .board-note > .title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
display: block;
|
||||
padding-inline-start: calc(var(--card-title-inset) - var(--card-padding));
|
||||
text-indent: calc(var(--card-icon-inset) - var(--card-title-inset));
|
||||
|
||||
/* The spans mark.js wraps a filter's matched tokens in. Matches the search result cards. */
|
||||
.ck-find-result {
|
||||
@ -441,16 +485,16 @@ body.mobile .board-view-container .board-add-column {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Inline, so the glyph sits on the title's own baseline. The trailing space assumes the pack draws
|
||||
its icons on a `--card-icon-size` advance, as the layout above already did. */
|
||||
.board-view-container .board-note > .title > .icon {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--card-icon-size);
|
||||
height: calc(var(--card-line-height) * 1em);
|
||||
margin-inline-start: calc(var(--card-icon-inset) - var(--card-padding));
|
||||
display: inline;
|
||||
margin-inline-end: calc(
|
||||
var(--card-title-inset) - var(--card-icon-inset) - var(--card-icon-size));
|
||||
/* A pack centres its ink on the em box, which sits above the middle of the title's capitals. In
|
||||
whole pixels, so the glyph keeps a fixed relation to the row the line is drawn on. */
|
||||
vertical-align: -1px;
|
||||
vertical-align: round(-0.0555em, 1px);
|
||||
}
|
||||
|
||||
.board-view-container .board-note > .edit-icon {
|
||||
@ -555,11 +599,55 @@ body.mobile .board-view-container .board-add-column {
|
||||
will-change: transform;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
cursor: move;
|
||||
opacity: 0.85;
|
||||
opacity: var(--board-drag-opacity);
|
||||
box-shadow: var(--board-drag-shadow);
|
||||
}
|
||||
|
||||
/* Opaque rather than tinted: while it is up, the message replaces the card's own content. */
|
||||
.board-view-container .board-drag-preview .board-drop-hint {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: inherit;
|
||||
padding: var(--card-padding);
|
||||
/* No saturation until `hint-tinted` sets it, so an unnamed hue paints grey rather than red. */
|
||||
background-color: hsl(
|
||||
var(--board-drop-hint-hue, 0),
|
||||
0%,
|
||||
var(--board-drop-hint-background-lightness)
|
||||
);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--board-drop-hint-color);
|
||||
transition: background-color 250ms ease, color 250ms ease;
|
||||
}
|
||||
|
||||
/* The card's own hue, or the hue of the column it is held over. */
|
||||
.board-view-container .board-drag-preview.hint-tinted .board-drop-hint {
|
||||
background-color: hsl(
|
||||
var(--board-drop-hint-hue),
|
||||
var(--board-drop-hint-background-saturation),
|
||||
var(--board-drop-hint-background-lightness)
|
||||
);
|
||||
}
|
||||
|
||||
/* No focus ring while the overlay is up: it would outline the overlay rather than the card. The
|
||||
opacity follows `--board-drag-visible`, 1 while the copy is in view and 0 against the edge. */
|
||||
.board-view-container .board-drag-preview.showing-drop-hint {
|
||||
outline: none;
|
||||
opacity: calc(
|
||||
var(--board-drag-opacity-faded)
|
||||
+ (var(--board-drag-opacity) - var(--board-drag-opacity-faded))
|
||||
* var(--board-drag-visible, 1)
|
||||
);
|
||||
}
|
||||
|
||||
.board-view-container .board-drag-preview.showing-drop-hint .board-drop-hint {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* What a carried selection is drawn as: one card-shaped block holding how many are on the move. */
|
||||
.board-view-container .board-note.board-drag-count {
|
||||
display: flex;
|
||||
|
||||
@ -211,6 +211,14 @@ function columnIcons(container: HTMLElement) {
|
||||
.map(el => [ ...el.classList ].filter(name => name.startsWith("bx")).join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in note context, with the two calls every mounted board makes of one: it publishes its
|
||||
* columns into the context for the right pane to list, and clears them as it goes.
|
||||
*/
|
||||
function contextStub(context: object) {
|
||||
return { setContextData: () => {}, clearContextData: () => {}, ...context };
|
||||
}
|
||||
|
||||
function columnTitles(container: HTMLElement) {
|
||||
return [ ...container.querySelectorAll(".board-column h3 .title") ].map(el => el.textContent);
|
||||
}
|
||||
@ -795,7 +803,7 @@ describe("A board in a tab the reader is not looking at", () => {
|
||||
const otherTab = {};
|
||||
let shown: object = tab;
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: { getMainContext: () => tab } });
|
||||
Object.assign(host, { noteContext: contextStub({ getMainContext: () => tab }) });
|
||||
const previousTabManager = appContext.tabManager;
|
||||
appContext.tabManager = { getActiveMainContext: () => shown } as never;
|
||||
|
||||
@ -862,7 +870,7 @@ describe("A board in a tab the reader is not looking at", () => {
|
||||
// One tab, and the board is in a pane of it that does not hold the focus.
|
||||
const tab = {};
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: { getMainContext: () => tab } });
|
||||
Object.assign(host, { noteContext: contextStub({ getMainContext: () => tab }) });
|
||||
const previousTabManager = appContext.tabManager;
|
||||
appContext.tabManager = { getActiveMainContext: () => tab } as never;
|
||||
|
||||
@ -1471,6 +1479,28 @@ describe("Board column rename", () => {
|
||||
await act(async () => { await flush(); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Two columns under one name would be merged by the rename, which a reader who has forgotten
|
||||
* the other column does not expect. The name is refused, and the editor stays open on it.
|
||||
*/
|
||||
it("refuses a name another column already has and keeps the editor open", async () => {
|
||||
const { container } = await setup();
|
||||
const put = vi.spyOn(server, "put").mockResolvedValue(undefined);
|
||||
const message = vi.spyOn(toast, "showMessage").mockReturnValue(undefined);
|
||||
|
||||
await renameColumnAt(container, 1, "Done");
|
||||
|
||||
expect(message).toHaveBeenCalledWith(
|
||||
"board_view.column-name-taken:{\"column\":\"Done\"}", undefined, "bx bx-duplicate");
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
|
||||
const columns = container.querySelectorAll<HTMLElement>(".board-column");
|
||||
expect(columns).toHaveLength(3);
|
||||
// The column being renamed shows the editor in place of its title.
|
||||
expect(columnTitles(container)).toEqual([ "To Do", "Done" ]);
|
||||
expect(columns[1].querySelector<HTMLInputElement>("h3 input")?.value).toBe("Done");
|
||||
});
|
||||
|
||||
/**
|
||||
* The cards, the stored columns and the definition are renamed together by the server, so that
|
||||
* no client reads the board while they disagree and writes the old name back. What the board
|
||||
@ -4212,7 +4242,7 @@ describe("Board properties from the note menu", () => {
|
||||
});
|
||||
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: { ntxId, isActive: () => true } });
|
||||
Object.assign(host, { noteContext: contextStub({ ntxId, isActive: () => true }) });
|
||||
|
||||
const mountPoint = document.createElement("div");
|
||||
container = mountPoint;
|
||||
@ -4282,6 +4312,20 @@ describe("a column windowed for its size", () => {
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* The drag places a card at a column's foot by this count. It has to stand on the column
|
||||
* itself: a collapsed column draws no card area to hang it off, and a windowed one draws fewer
|
||||
* cards than it holds.
|
||||
*/
|
||||
it("says on each column how many cards it holds, drawn or not", async () => {
|
||||
const board = await renderSized(200, 3);
|
||||
const columns = board.querySelectorAll<HTMLElement>(".board-column");
|
||||
|
||||
expect(columns[0].dataset.count).toBe("200");
|
||||
expect(columns[0].querySelectorAll(".board-note").length).toBeLessThan(200);
|
||||
expect(columns[1].dataset.count).toBe("3");
|
||||
});
|
||||
|
||||
/**
|
||||
* The drag and the keyboard both name a card by the place it holds in its column. Counting
|
||||
* drawn elements would name a place among whatever is on screen, which moves as it scrolls.
|
||||
@ -4332,3 +4376,153 @@ describe("a column windowed for its size", () => {
|
||||
return mountPoint;
|
||||
}
|
||||
});
|
||||
|
||||
describe("how wide the board draws its columns", () => {
|
||||
let container: HTMLElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
saved.length = 0;
|
||||
if (container) {
|
||||
render(null, container);
|
||||
container.remove();
|
||||
container = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The width itself is CSS, which happy-dom does not resolve. What the board answers for is the
|
||||
* class it carries, off which the stylesheet picks one of the three widths.
|
||||
*/
|
||||
it("carries the width its label names, and no class where it names none", async () => {
|
||||
expect((await draw("wide")).className).toContain("board-wide-columns");
|
||||
expect((await draw("narrow")).className).toContain("board-narrow-columns");
|
||||
|
||||
// Without a class the width is whatever is inherited, which is the stylesheet's own
|
||||
// default or a theme's if one sets it.
|
||||
expect((await draw(undefined)).className).not.toMatch(/board-\w+-columns/);
|
||||
expect((await draw("enormous")).className).not.toMatch(/board-\w+-columns/);
|
||||
});
|
||||
|
||||
async function draw(width: string | undefined) {
|
||||
if (container) {
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
|
||||
const note = buildNote({
|
||||
title: "Board",
|
||||
"#collection": "",
|
||||
"#viewType": "board",
|
||||
...(width ? { "#boardCardWidth": width } : {}),
|
||||
children: [ { title: "First", "#status": "To Do" } ]
|
||||
});
|
||||
|
||||
const mountPoint = document.createElement("div");
|
||||
container = mountPoint;
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<ParentComponent.Provider value={new Component()}>
|
||||
<Harness
|
||||
note={note}
|
||||
noteIds={[ ...note.getChildNoteIds() ]}
|
||||
initialConfig={{ columns: [ { value: "To Do" } ] }}
|
||||
/>
|
||||
</ParentComponent.Provider>,
|
||||
mountPoint
|
||||
);
|
||||
});
|
||||
await act(async () => { await flush(); });
|
||||
|
||||
const board = mountPoint.querySelector<HTMLElement>(".board-view");
|
||||
if (!board) throw new Error("expected the board to be drawn");
|
||||
return board;
|
||||
}
|
||||
});
|
||||
|
||||
describe("what the board hands the right pane", () => {
|
||||
let container: HTMLElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
saved.length = 0;
|
||||
if (container) {
|
||||
render(null, container);
|
||||
container.remove();
|
||||
container = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane lists the columns of whichever board is on show, so the board publishes them into
|
||||
* the context it belongs to rather than the pane reading the board's own state.
|
||||
*/
|
||||
it("publishes its columns, scrolls to one on request, and takes them back", async () => {
|
||||
const published: { key: string, value: unknown }[] = [];
|
||||
const cleared: string[] = [];
|
||||
const note = buildNote({
|
||||
title: "Board",
|
||||
"#collection": "",
|
||||
"#viewType": "board",
|
||||
children: [
|
||||
{ title: "First", "#status": "To Do" },
|
||||
{ title: "Second", "#status": "Done" },
|
||||
{ title: "Third", "#status": "Done" }
|
||||
]
|
||||
});
|
||||
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: {
|
||||
setContextData: (key: string, value: unknown) => published.push({ key, value }),
|
||||
clearContextData: (key: string) => cleared.push(key)
|
||||
} });
|
||||
|
||||
const mountPoint = document.createElement("div");
|
||||
container = mountPoint;
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<ParentComponent.Provider value={host}>
|
||||
<Harness
|
||||
note={note}
|
||||
noteIds={[ ...note.getChildNoteIds() ]}
|
||||
initialConfig={{ columns: [ { value: "To Do" }, { value: "Done" } ] }}
|
||||
/>
|
||||
</ParentComponent.Provider>,
|
||||
mountPoint
|
||||
);
|
||||
});
|
||||
await act(async () => { await flush(); });
|
||||
|
||||
const outline = published.at(-1);
|
||||
expect(outline?.key).toBe("boardColumns");
|
||||
const { columns, scrollToColumn } = outline?.value as {
|
||||
columns: { value: string, title: string, count: number }[],
|
||||
scrollToColumn: (column: string) => void
|
||||
};
|
||||
expect(columns.map(({ title, count }) => `${title}:${count}`))
|
||||
.toEqual([ "To Do:1", "Done:2" ]);
|
||||
|
||||
// What a press on one of the pane's entries does. Both are recorded rather than watched
|
||||
// for: happy-dom scrolls nothing, and a dialog another spec left standing holds the focus.
|
||||
const scrolled: (string | undefined)[] = [];
|
||||
const focused: (string | undefined)[] = [];
|
||||
for (const element of mountPoint.querySelectorAll<HTMLElement>(".board-column")) {
|
||||
element.scrollIntoView = () => scrolled.push(element.dataset.column);
|
||||
const heading = element.querySelector<HTMLElement>("h3");
|
||||
if (heading) {
|
||||
heading.focus = () => focused.push(element.dataset.column);
|
||||
}
|
||||
}
|
||||
|
||||
scrollToColumn("Done");
|
||||
expect(scrolled).toEqual([ "Done" ]);
|
||||
// The heading takes the focus with it, so the board's own keys carry on from there.
|
||||
expect(focused).toEqual([ "Done" ]);
|
||||
|
||||
// A board that is no longer on show leaves nothing behind for the pane to list.
|
||||
act(() => { render(null, mountPoint); });
|
||||
expect(cleared).toContain("boardColumns");
|
||||
});
|
||||
});
|
||||
|
||||
@ -31,8 +31,8 @@ import CollectionProperties from "../../note_bars/CollectionProperties";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import {
|
||||
useContextualShortcutHints, useNoteContext, useNoteLabelBoolean, useNoteLabelWithDefault,
|
||||
useNoteTypeOptions, useTrackedElement, useTriliumEvent
|
||||
useContextualShortcutHints, useNoteContext, useNoteLabel, useNoteLabelBoolean,
|
||||
useNoteLabelWithDefault, useNoteTypeOptions, useSetContextData, useTrackedElement, useTriliumEvent
|
||||
} from "../../react/hooks";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete";
|
||||
@ -53,7 +53,10 @@ import { forgetWindowHeights } from "./windowing";
|
||||
import { BoardDropStateContext, DropStateStore } from "./drop_state";
|
||||
import BoardApi from "./api";
|
||||
import { adoptLegacyColumns, readColumns } from "./column_storage";
|
||||
import { DEFAULT_COLUMN_ICON, DEFAULT_GROUP_BY, getStatusDefinition, INBOX_COLUMN } from "./columns";
|
||||
import {
|
||||
COLUMN_WIDTH_LABEL, columnWidthClass, DEFAULT_COLUMN_ICON, DEFAULT_GROUP_BY,
|
||||
getStatusDefinition, INBOX_COLUMN
|
||||
} from "./columns";
|
||||
import Column, { EXPAND_MS, placeCard, settleCards } from "./column";
|
||||
import { currentCardTemplate, DEFAULT_CARD_TEMPLATES } from "./card_templates";
|
||||
import ColumnLimitDialog from "./column_limit";
|
||||
@ -351,6 +354,9 @@ export default function BoardView({
|
||||
let viewConfig = adoptedConfig ?? storedConfig;
|
||||
const [ includeArchived ] = useNoteLabelBoolean(parentNote, "includeArchived");
|
||||
const [ inboxEnabled ] = useNoteLabelBoolean(parentNote, "enableInboxColumn");
|
||||
// Read undefaulted: a board naming no width wears no class, so `--board-column-width` keeps
|
||||
// whatever it inherits.
|
||||
const [ storedColumnWidth ] = useNoteLabel(parentNote, COLUMN_WIDTH_LABEL);
|
||||
/** Every card the board holds, which an active filter narrows before the cards are drawn. */
|
||||
const [ allByColumn, setAllByColumn ] = useState<ColumnMap>();
|
||||
const [ columns, setColumns ] = useState<string[]>();
|
||||
@ -626,6 +632,24 @@ export default function BoardView({
|
||||
/** Until when a column move can still be settling, which is when `useFlip` slides columns. */
|
||||
const columnMovedUntil = useRef(0);
|
||||
|
||||
// What the right pane lists the board as, and what a press on one of its entries does.
|
||||
//
|
||||
// Held still between renders: the pane redraws its list whenever this changes identity, and a
|
||||
// board renders on every step of a drag. `storedColumns` is among what it is held against
|
||||
// because the api reads the config in place, so nothing else here changes when it does.
|
||||
const outline = useMemo(() => ({
|
||||
columns: api.getColumnOutline(shownColumns),
|
||||
scrollToColumn: (column: string) => {
|
||||
const element = columnElement(containerRef.current, column);
|
||||
element?.scrollIntoView({ inline: "start", block: "nearest", behavior: "smooth" });
|
||||
// The heading takes the focus, so the board's own keys carry on from the column the
|
||||
// reader picked. Scrolled first, and without moving anything itself: focus landing on
|
||||
// its own would jump the board to the column the scroll is already easing towards.
|
||||
element?.querySelector<HTMLElement>("h3")?.focus({ preventScroll: true });
|
||||
}
|
||||
}), [ api, shownColumns, byColumn, storedColumns, isInRelationMode ]);
|
||||
useSetContextData(noteContext, "boardColumns", outline);
|
||||
|
||||
// Neither the creation dates the tie-break needs nor the targets of a sorted relation come
|
||||
// with the board. Both are fetched here, and `sortRevision` redraws it once they land.
|
||||
useEffect(() => {
|
||||
@ -965,13 +989,8 @@ export default function BoardView({
|
||||
if (!isMobile()) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const columns = containerRef.current?.querySelectorAll<HTMLElement>(".board-column");
|
||||
for (const element of columns ?? []) {
|
||||
if (element.dataset.column === column) {
|
||||
element.scrollIntoView({ inline: "center", block: "nearest" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
columnElement(containerRef.current, column)
|
||||
?.scrollIntoView({ inline: "center", block: "nearest" });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -1198,7 +1217,7 @@ export default function BoardView({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={clsx("board-view", {
|
||||
<div className={clsx("board-view", columnWidthClass(storedColumnWidth), {
|
||||
frozen: isFrozen,
|
||||
"editing-open": branchIdToEdit !== undefined || insertingColumns.size > 0
|
||||
})}>
|
||||
@ -1410,6 +1429,15 @@ function closeGaps(container: HTMLElement | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The element a column is drawn in, for the two things that scroll the board to one. */
|
||||
function columnElement(container: HTMLElement | null, column: string) {
|
||||
for (const element of container?.querySelectorAll<HTMLElement>(".board-column") ?? []) {
|
||||
if (element.dataset.column === column) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findRefreshReason(loadResults: LoadResults, statusAttribute: string, noteIds: string[], parentNoteId: string): string | null {
|
||||
// A card moved between columns.
|
||||
if (loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId ?? ""))) {
|
||||
@ -1536,7 +1564,11 @@ export function TitleEditor({
|
||||
}: {
|
||||
currentValue?: string;
|
||||
placeholder?: string;
|
||||
save: (newValue: string, atStart?: boolean) => void | Promise<void>;
|
||||
/**
|
||||
* Writes what was typed. Returns `false` to refuse it, which keeps the editor open on what it
|
||||
* holds so the reader can correct it.
|
||||
*/
|
||||
save: (newValue: string, atStart?: boolean) => false | void | Promise<void>;
|
||||
dismiss: () => void;
|
||||
isNewItem?: boolean;
|
||||
mode?: "normal" | "multiline" | "relation";
|
||||
@ -1678,8 +1710,9 @@ export function TitleEditor({
|
||||
// editor opened by a press on the thing it edits, rather than from something focused,
|
||||
// has nowhere to send it, so Enter says here what that blur would have said.
|
||||
const typed = inputRef.current?.value ?? "";
|
||||
if (e.key === "Enter" && typed.trim() && (typed !== currentValue || isNewItem)) {
|
||||
commit(typed);
|
||||
if (e.key === "Enter" && typed.trim() && (typed !== currentValue || isNewItem)
|
||||
&& !commit(typed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dismiss();
|
||||
@ -1701,7 +1734,10 @@ export function TitleEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
commit(value, atStart);
|
||||
if (!commit(value, atStart)) {
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handsOver) {
|
||||
hasHandedOver.current = true;
|
||||
@ -1782,21 +1818,36 @@ export function TitleEditor({
|
||||
}
|
||||
|
||||
if (!shouldDismiss.current && newValue.trim() && (newValue !== currentValue || isNewItem)) {
|
||||
commit(newValue);
|
||||
if (!commit(newValue)) {
|
||||
// The field stays open, so the focus this blur took off it has to come back.
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
dismissOnNextRefreshRef.current = true;
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
// The editor is closing either way, and what a save writes has already been put back by
|
||||
// whatever could not write it; all that is left is to say so rather than to reject unhandled,
|
||||
// which is what a save reaching nobody used to do.
|
||||
/**
|
||||
* Saves what was typed and reports whether `save` accepted it.
|
||||
*
|
||||
* A refusal is reported by `save` itself, which is what knows why it refused. A save that
|
||||
* fails later is reported here instead of rejecting unhandled: the editor has closed by then,
|
||||
* and whatever could not be written has already been put back.
|
||||
*/
|
||||
function commit(newValue: string, atStart?: boolean) {
|
||||
Promise.resolve(save(newValue, atStart)).catch((e) => {
|
||||
const outcome = save(newValue, atStart);
|
||||
if (outcome === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Promise.resolve(outcome).catch((e) => {
|
||||
console.error("Failed to save what the board editor was given:", e);
|
||||
toast.showError(t("board_view.save-error"));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mode !== "relation") {
|
||||
@ -1908,7 +1959,10 @@ export function TitleEditor({
|
||||
}}
|
||||
onBlur={() => dismiss()}
|
||||
noteIdChanged={(newValue) => {
|
||||
save(newValue);
|
||||
if (newValue && !commit(newValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dismiss();
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -62,6 +62,8 @@ describe("Board properties", () => {
|
||||
let labels: Record<string, string>;
|
||||
/** What the board was asked to do with the order it holds. */
|
||||
let sorting: string[];
|
||||
/** The widths the board was asked to draw its columns at. */
|
||||
let widths: string[];
|
||||
/** Draws the dialog again, for a test that changed what the board says. */
|
||||
let draw: () => void;
|
||||
|
||||
@ -70,6 +72,7 @@ describe("Board properties", () => {
|
||||
storedAttributes = [];
|
||||
toggled = [];
|
||||
sorting = [];
|
||||
widths = [];
|
||||
labels = { includeArchived: "true" };
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@ -102,7 +105,8 @@ describe("Board properties", () => {
|
||||
setDefaultSortDirection: async (isDescending: boolean) => {
|
||||
sorting.push(`descending:${isDescending}`);
|
||||
},
|
||||
resetColumnSortsToDefault: async () => { sorting.push("reset"); }
|
||||
resetColumnSortsToDefault: async () => { sorting.push("reset"); },
|
||||
setColumnWidth: async (width: string) => { widths.push(width); }
|
||||
} as unknown as BoardApi;
|
||||
|
||||
draw = () => act(() => {
|
||||
@ -154,7 +158,7 @@ describe("Board properties", () => {
|
||||
const rows = general()
|
||||
?.querySelectorAll(".tn-card-section:not(.tn-card-section-nested)") ?? [];
|
||||
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows.length).toBe(4);
|
||||
expect(toggleAt(0)?.classList.contains("on")).toBe(false);
|
||||
expect(toggleAt(1)?.classList.contains("on")).toBe(true);
|
||||
});
|
||||
@ -217,6 +221,44 @@ describe("Board properties", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("how wide it draws its columns", () => {
|
||||
it("offers the three widths the board knows", () => {
|
||||
// happy-dom does not follow which option Preact marks as selected, so what the board
|
||||
// holds is covered by `parseColumnWidth` and the api instead.
|
||||
expect([ ...(picker()?.options ?? []) ].map((option) => option.textContent)).toEqual([
|
||||
"board_view.column-width-narrow",
|
||||
"board_view.column-width-medium",
|
||||
"board_view.column-width-wide"
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks the board for the width that was picked", () => {
|
||||
const select = picker();
|
||||
if (!select) throw new Error("expected a width picker on the board properties");
|
||||
|
||||
act(() => {
|
||||
select.value = "medium";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(widths).toEqual([ "medium" ]);
|
||||
});
|
||||
|
||||
/** A phone lays the columns out at a width of its own, so there is nothing to pick. */
|
||||
it("is left out on mobile", () => {
|
||||
window.glob.device = "mobile";
|
||||
act(() => { render(null, container); });
|
||||
draw();
|
||||
|
||||
expect(picker()).toBeFalsy();
|
||||
window.glob.device = "desktop";
|
||||
});
|
||||
|
||||
function picker() {
|
||||
return general()?.querySelector<HTMLSelectElement>("select");
|
||||
}
|
||||
});
|
||||
|
||||
it("holds the promoted attributes, in the board's own words and from its own config", () => {
|
||||
const card = dialog()?.querySelector<HTMLElement>(".attributes-stub");
|
||||
|
||||
|
||||
@ -5,9 +5,11 @@ import { useCallback } from "preact/hooks";
|
||||
import type FNote from "../../../entities/fnote";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { isMobile } from "../../../services/utils";
|
||||
import { Card, CardSection, OptionCardSection } from "../../react/Card";
|
||||
import FormSelect from "../../react/FormSelect";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useNoteLabelBoolean } from "../../react/hooks";
|
||||
import { useNoteLabelBoolean, useNoteLabelWithDefault } from "../../react/hooks";
|
||||
import Modal from "../../react/Modal";
|
||||
import PromotedAttributesCard from "../../react/PromotedAttributesCard";
|
||||
import TemplateSelectionCard from "../../react/TemplateSelectionCard";
|
||||
@ -15,6 +17,7 @@ import type { PromotedAttribute } from "../promoted_attributes";
|
||||
import SortDropdown from "../SortDropdown";
|
||||
import { parseSortKey } from "../sorting";
|
||||
import BoardApi from "./api";
|
||||
import { COLUMN_WIDTH_LABEL, DEFAULT_COLUMN_WIDTH, parseColumnWidth } from "./columns";
|
||||
import { useBoardSort } from "./sort";
|
||||
|
||||
/** The board's settings, other than its columns and cards. */
|
||||
@ -66,6 +69,8 @@ export default function BoardProperties({ api, note, shown, onClose }: {
|
||||
function General({ api, note }: { api: BoardApi, note: FNote }) {
|
||||
const [ inboxShown ] = useNoteLabelBoolean(note, "enableInboxColumn");
|
||||
const [ archivedShown ] = useNoteLabelBoolean(note, "includeArchived");
|
||||
const [ columnWidth ] =
|
||||
useNoteLabelWithDefault(note, COLUMN_WIDTH_LABEL, DEFAULT_COLUMN_WIDTH);
|
||||
const defaultSort = useBoardSort(note);
|
||||
// Every column at once, and a column's own order is not kept anywhere else: the reader is asked
|
||||
// before it goes.
|
||||
@ -98,6 +103,27 @@ function General({ api, note }: { api: BoardApi, note: FNote }) {
|
||||
/>
|
||||
</OptionCardSection>
|
||||
|
||||
{/* `body.mobile` in index.css fixes the column width against the viewport, which is
|
||||
what the three widths here would otherwise set. */}
|
||||
{!isMobile() && (
|
||||
<OptionCardSection
|
||||
name="board-column-width"
|
||||
label={t("board_view.column-width")}
|
||||
>
|
||||
<FormSelect
|
||||
values={[
|
||||
{ value: "narrow", title: t("board_view.column-width-narrow") },
|
||||
{ value: "medium", title: t("board_view.column-width-medium") },
|
||||
{ value: "wide", title: t("board_view.column-width-wide") }
|
||||
]}
|
||||
keyProperty="value"
|
||||
titleProperty="title"
|
||||
currentValue={parseColumnWidth(columnWidth)}
|
||||
onChange={(width) => api.setColumnWidth(parseColumnWidth(width))}
|
||||
/>
|
||||
</OptionCardSection>
|
||||
)}
|
||||
|
||||
<OptionCardSection
|
||||
name="board-sort-cards"
|
||||
label={t("board_view.default-card-order")}
|
||||
|
||||
@ -11,7 +11,9 @@ div.note-icon-widget {
|
||||
}
|
||||
|
||||
.note-icon-widget button.note-icon {
|
||||
--size: calc(var(--note-icon-size) + var(--note-icon-container-padding-size) * 2);
|
||||
/* `1em` is this button's font size, which `--note-icon-size` already set: naming that variable
|
||||
here again would scale a value given in `em` a second time. */
|
||||
--size: calc(1em + var(--note-icon-container-padding-size) * 2);
|
||||
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
@ -23,6 +25,17 @@ div.note-icon-widget {
|
||||
font-size: var(--note-icon-size);
|
||||
}
|
||||
|
||||
/* A glyph rasterises on whole pixels while the disc behind it does not, so a gap of half a pixel
|
||||
between the two puts the icon high or low depending on where the widget lands on the page.
|
||||
Rounding both leaves a gap of twice the ring, which is always whole. */
|
||||
@supports (width: round(1px, 1px)) {
|
||||
.note-icon-widget button.note-icon {
|
||||
--size: calc(1em + round(var(--note-icon-container-padding-size), 1px) * 2);
|
||||
|
||||
font-size: round(var(--note-icon-size), 1px);
|
||||
}
|
||||
}
|
||||
|
||||
.note-icon-widget button.note-icon:disabled {
|
||||
cursor: default;
|
||||
opacity: .75;
|
||||
|
||||
30
apps/client/src/widgets/sidebar/BoardColumns.css
Normal file
30
apps/client/src/widgets/sidebar/BoardColumns.css
Normal file
@ -0,0 +1,30 @@
|
||||
.board-columns-list ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.board-columns-list .board-column-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.25em 0.4em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-columns-list .board-column-entry:hover {
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-columns-list .board-column-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.board-columns-list .no-columns {
|
||||
padding: 0.4em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
83
apps/client/src/widgets/sidebar/BoardColumns.spec.tsx
Normal file
83
apps/client/src/widgets/sidebar/BoardColumns.spec.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NoteContextDataMap } from "../../components/note_context";
|
||||
import options from "../../services/options";
|
||||
import BoardColumns from "./BoardColumns";
|
||||
|
||||
// The board publishes its columns through the note context, which needs the whole app around it,
|
||||
// so the panel is handed them directly here.
|
||||
const shown = vi.hoisted(() => ({
|
||||
board: null as NoteContextDataMap["boardColumns"] | null
|
||||
}));
|
||||
vi.mock("../../services/i18n", () => ({ t: (key: string) => key }));
|
||||
vi.mock("../react/hooks", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../react/hooks")>()),
|
||||
useGetContextData: () => shown.board
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// The widget stands in a RightPanelWidget, which reads which panels the user collapsed.
|
||||
options.set("rightPaneCollapsedItems", JSON.stringify([]));
|
||||
shown.board = {
|
||||
columns: [
|
||||
{ value: "", title: "Inbox", icon: "bx bxs-inbox", count: 2 },
|
||||
{ value: "To Do", title: "To Do", icon: "bx bx-circle", count: 7 },
|
||||
{ value: "Done", title: "Done", icon: "bx bx-check", count: 0 }
|
||||
],
|
||||
scrollToColumn: vi.fn()
|
||||
};
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => render(null, container));
|
||||
container.remove();
|
||||
shown.board = null;
|
||||
});
|
||||
|
||||
describe("BoardColumns", () => {
|
||||
it("lists the columns in the board's own order, each with its icon and how many it holds", () => {
|
||||
renderPanel();
|
||||
|
||||
const entries = [ ...container.querySelectorAll(".board-column-entry") ];
|
||||
expect(entries.map((entry) => entry.querySelector(".board-column-name")?.textContent))
|
||||
.toEqual([ "Inbox", "To Do", "Done" ]);
|
||||
expect(entries.map((entry) => entry.querySelector(".badge")?.textContent))
|
||||
.toEqual([ "2", "7", "0" ]);
|
||||
expect(entries[1].querySelector("span.bx-circle")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks the board to scroll to the column that was pressed, by mouse or by keyboard", () => {
|
||||
renderPanel();
|
||||
const entries = [ ...container.querySelectorAll<HTMLElement>(".board-column-entry") ];
|
||||
|
||||
entries[2].click();
|
||||
expect(shown.board?.scrollToColumn).toHaveBeenCalledWith("Done");
|
||||
|
||||
act(() => {
|
||||
entries[0].dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
// The inbox is named by the empty value, which is what it is identified by throughout.
|
||||
expect(shown.board?.scrollToColumn).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
|
||||
it("says so for a board with no columns at all", () => {
|
||||
shown.board = { columns: [], scrollToColumn: vi.fn() };
|
||||
renderPanel();
|
||||
|
||||
expect(container.querySelector(".board-column-entry")).toBeNull();
|
||||
expect(container.querySelector(".no-columns")?.textContent)
|
||||
.toBe("board_view.columns-empty");
|
||||
});
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
act(() => render(<BoardColumns />, container));
|
||||
}
|
||||
51
apps/client/src/widgets/sidebar/BoardColumns.tsx
Normal file
51
apps/client/src/widgets/sidebar/BoardColumns.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import "./BoardColumns.css";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import SimpleBadge from "../react/Badge";
|
||||
import { useGetContextData } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import RightPanelWidget from "./RightPanelWidget";
|
||||
|
||||
/**
|
||||
* The board's columns, in the order it draws them, so that one of them can be reached on a board
|
||||
* too wide to see at once. The board publishes the list and the scrolling; this widget draws it.
|
||||
*/
|
||||
export default function BoardColumns() {
|
||||
const data = useGetContextData("boardColumns");
|
||||
const columns = data?.columns ?? [];
|
||||
|
||||
return (
|
||||
<RightPanelWidget id="board-columns" title={t("board_view.columns-title")} grow>
|
||||
<div className="board-columns-list">
|
||||
{columns.length > 0 ? (
|
||||
<ol>
|
||||
{columns.map(column => (
|
||||
<li key={column.value}>
|
||||
{/* The row is the scroll target, reachable by keyboard as the
|
||||
other lists of the pane are. */}
|
||||
<span
|
||||
className="board-column-entry"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => data?.scrollToColumn(column.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
data?.scrollToColumn(column.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={column.icon} />
|
||||
<span className="board-column-name">{column.title}</span>
|
||||
<SimpleBadge title={column.count} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="no-columns">{t("board_view.columns-empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ import { PaneMode, usePaneMode, usePeekDismiss } from "../react/peek_pane";
|
||||
import LegacyRightPanelWidget from "../right_panel_widget";
|
||||
import AttributeList from "./AttributeList";
|
||||
import Backlinks from "./Backlinks";
|
||||
import BoardColumns from "./BoardColumns";
|
||||
import ChatHighlightsList from "./ChatHighlightsList";
|
||||
import HighlightsList from "./HighlightsList";
|
||||
import NoteMap from "./NoteMap";
|
||||
@ -203,6 +204,8 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent):
|
||||
// Published by the LLM chat; drives the chat highlights widget's visibility (only shown once
|
||||
// the chat has at least one highlight).
|
||||
const chatHighlights = useGetContextData("chatHighlights");
|
||||
// Published by a board, which is what says the note is being shown as one.
|
||||
const boardColumns = useGetContextData("boardColumns");
|
||||
// Subscribe to the AI toggle so the LLM chat is added/removed reactively without a page reload.
|
||||
const [ aiEnabled ] = useTriliumOptionBool("aiEnabled");
|
||||
const isPdf = noteType === "file" && noteMime === "application/pdf";
|
||||
@ -268,6 +271,13 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent):
|
||||
enabled: (noteType === "text" || !!note?.isMarkdown()) && highlightsList.length > 0,
|
||||
tab: "outline"
|
||||
},
|
||||
{
|
||||
el: <BoardColumns />,
|
||||
// Gated on being a board rather than on having columns, so that a board with none of
|
||||
// them shows the card's own empty state.
|
||||
enabled: boardColumns !== undefined,
|
||||
tab: "outline"
|
||||
},
|
||||
{
|
||||
el: <ChatHighlightsList />,
|
||||
enabled: noteType === "llmChat" && (chatHighlights?.highlights.length ?? 0) > 0,
|
||||
|
||||
@ -7,8 +7,12 @@
|
||||
"start": "tsx ."
|
||||
},
|
||||
"keywords": [],
|
||||
"dependencies": {
|
||||
"@triliumnext/commons": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"@phosphor-icons/web": "2.1.2"
|
||||
"@phosphor-icons/web": "2.1.2",
|
||||
"opentype.js": "2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { IconPackManifest } from "@triliumnext/server/src/services/icon_packs";
|
||||
import type { IconPackManifest } from "@triliumnext/core/src/services/icon_packs";
|
||||
|
||||
export interface IconPackData {
|
||||
name: string;
|
||||
|
||||
@ -2,6 +2,7 @@ import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { IconPackData } from "../provider";
|
||||
import { readIconFontMetrics } from "../utils";
|
||||
|
||||
export default function buildIcons(pack: "basic" | "brands"): IconPackData {
|
||||
const inputDir = join(__dirname, "../../boxicons-free/fonts");
|
||||
@ -36,7 +37,8 @@ export default function buildIcons(pack: "basic" | "brands"): IconPackData {
|
||||
content: readFileSync(join(inputDir, pack, `${fileName}.woff2`))
|
||||
},
|
||||
manifest: {
|
||||
icons
|
||||
icons,
|
||||
metrics: readIconFontMetrics(join(inputDir, pack, `${fileName}.ttf`))
|
||||
},
|
||||
meta: {
|
||||
version: "3.0.0",
|
||||
|
||||
@ -2,7 +2,7 @@ import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import type { IconPackData } from "../provider";
|
||||
import { extractClassNamesFromCss, getModulePath } from "../utils";
|
||||
import { extractClassNamesFromCss, getModulePath, readIconFontMetrics } from "../utils";
|
||||
|
||||
export default function buildIcons(): IconPackData {
|
||||
const baseDir = getModulePath("@mdi/font");
|
||||
@ -17,6 +17,7 @@ export default function buildIcons(): IconPackData {
|
||||
icon: "mdi mdi-material-design",
|
||||
manifest: {
|
||||
icons: extractClassNamesFromCss(cssFileContent, "mdi"),
|
||||
metrics: readIconFontMetrics(join(baseDir, "fonts", "materialdesignicons-webfont.ttf"))
|
||||
},
|
||||
fontFile: {
|
||||
name: "materialdesignicons-webfont.woff2",
|
||||
|
||||
@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { IconPackData } from "../provider";
|
||||
import { getModulePath } from "../utils";
|
||||
import { getModulePath, readIconFontMetrics } from "../utils";
|
||||
|
||||
export default function buildIcons(packName: "regular" | "fill"): IconPackData {
|
||||
const moduleDir = getModulePath("@phosphor-icons/web");
|
||||
@ -30,6 +30,10 @@ export default function buildIcons(packName: "regular" | "fill"): IconPackData {
|
||||
}
|
||||
|
||||
const fontFile = readdirSync(baseDir).find(f => f.endsWith(".woff2"));
|
||||
if (!fontFile) {
|
||||
throw new Error(`No WOFF2 font to build the pack from in ${baseDir}.`);
|
||||
}
|
||||
|
||||
const prefix = packName === "regular" ? "ph" : `ph-${packName}`;
|
||||
|
||||
return {
|
||||
@ -37,12 +41,13 @@ export default function buildIcons(packName: "regular" | "fill"): IconPackData {
|
||||
prefix,
|
||||
icon: `${prefix} ph-phosphor-logo`,
|
||||
manifest: {
|
||||
icons
|
||||
icons,
|
||||
metrics: readIconFontMetrics(join(baseDir, fontFile.replace(/\.woff2$/, ".ttf")))
|
||||
},
|
||||
fontFile: {
|
||||
name: fontFile!,
|
||||
name: fontFile,
|
||||
mime: "font/woff2",
|
||||
content: readFileSync(join(baseDir, fontFile!))
|
||||
content: readFileSync(join(baseDir, fontFile))
|
||||
},
|
||||
meta: {
|
||||
version: packageJson.version,
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { IconPackManifest } from "../../server/src/services/icon_packs";
|
||||
import { measureIconFont } from "@triliumnext/commons";
|
||||
import opentype from "opentype.js";
|
||||
|
||||
import { IconPackManifest } from "@triliumnext/core/src/services/icon_packs";
|
||||
|
||||
export function extractClassNamesFromCss(css: string, prefix: string): IconPackManifest["icons"] {
|
||||
const regex = /\.([a-zA-Z0-9-]+)::before\s*\{\s*content:\s*"\\([A-Fa-f0-9]+)"\s*\}/g;
|
||||
@ -24,3 +28,36 @@ export function extractClassNamesFromCss(css: string, prefix: string): IconPackM
|
||||
export function getModulePath(moduleName: string): string {
|
||||
return join(__dirname, "../../../node_modules", moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The metrics of an icon font, read off its glyphs, for the pack's manifest to carry.
|
||||
*
|
||||
* Packs ship as WOFF2, which cannot be taken apart without a Brotli decoder, so this reads the
|
||||
* TrueType build every provider has beside it. The two hold the same outlines.
|
||||
*
|
||||
* @param fontPath the `.ttf` to measure.
|
||||
* @returns the metrics, or `undefined` where the font cannot be read.
|
||||
*/
|
||||
export function readIconFontMetrics(fontPath: string): IconPackManifest["metrics"] {
|
||||
let font;
|
||||
try {
|
||||
const bytes = readFileSync(fontPath);
|
||||
font = opentype.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length));
|
||||
} catch (e) {
|
||||
console.warn(`Could not read font metrics from ${fontPath}: ${e}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inkCentres: number[] = [];
|
||||
for (const glyph of Object.values(font.glyphs.glyphs)) {
|
||||
if (!glyph.unicode) continue;
|
||||
|
||||
const box = glyph.getBoundingBox();
|
||||
// A blank glyph reports an inverted box, and says nothing about where the pack draws.
|
||||
if (box.y2 > box.y1) {
|
||||
inkCentres.push((box.y1 + box.y2) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
return measureIconFont(font.unitsPerEm, inkCentres) ?? undefined;
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ export * from "./lib/test-utils.js";
|
||||
export * from "./lib/attachment_roles.js";
|
||||
export * from "./lib/custom_fonts.js";
|
||||
export * from "./lib/font_mimes.js";
|
||||
export * from "./lib/icon_font_metrics.js";
|
||||
export * from "./lib/image_mimes.js";
|
||||
export * from "./lib/mime_type.js";
|
||||
export * from "./lib/office.js";
|
||||
|
||||
@ -271,6 +271,9 @@ const BUILTIN_ATTRIBUTES = [
|
||||
{ type: "label", name: "enableInboxColumn", valueType: "boolean", hasUserValue: true },
|
||||
// Carried by a card that stands in for another note: opening it navigates there instead.
|
||||
{ type: "relation", name: "boardCardRedirectTo" },
|
||||
// How wide the board draws its columns. Absent for the narrow default.
|
||||
{ type: "label", name: "boardCardWidth", valueType: "select", hasUserValue: true,
|
||||
selectOptions: [ "narrow", "medium", "wide" ] },
|
||||
// The order a board offers for its columns, which its properties apply to every column at once.
|
||||
{ type: "label", name: "sortColumns", valueType: "text", hasUserValue: true },
|
||||
{ type: "label", name: "sortColumnsDescending", valueType: "boolean", hasUserValue: true },
|
||||
|
||||
64
packages/commons/src/lib/icon_font_metrics.spec.ts
Normal file
64
packages/commons/src/lib/icon_font_metrics.spec.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { iconFontFaceOverrides, measureIconFont } from "./icon_font_metrics.js";
|
||||
|
||||
/** Ink centres for a pack drawn `offset` units above the baseline, with a little scatter. */
|
||||
function pack(offset: number, count = 20) {
|
||||
return Array.from({ length: count }, (_, index) => offset + (index % 5) - 2);
|
||||
}
|
||||
|
||||
describe("measureIconFont", () => {
|
||||
it("splits the em around the middle of the ink", () => {
|
||||
// Boxicons 2: 1024 units to the em, drawn 448 of them above the baseline.
|
||||
expect(measureIconFont(1024, pack(448))).toEqual({ ascent: 0.9375, descent: 0.0625 });
|
||||
|
||||
// Boxicons 3: the em sits on the baseline, so the box is all ascent.
|
||||
expect(measureIconFont(300, pack(150))).toEqual({ ascent: 1, descent: 0 });
|
||||
|
||||
// Material Design Icons: 512 units to the em, 192 above the baseline.
|
||||
expect(measureIconFont(512, pack(192))).toEqual({ ascent: 0.875, descent: 0.125 });
|
||||
|
||||
// A pack of an odd number of glyphs, where the middle one is the median outright.
|
||||
expect(measureIconFont(1024, pack(448, 21))).toEqual({ ascent: 0.9375, descent: 0.0625 });
|
||||
});
|
||||
|
||||
it("takes the middle of the pack, not the icons drawn off centre on purpose", () => {
|
||||
const centres = [ ...pack(448), 0, 12, 1000, 1010 ];
|
||||
|
||||
expect(measureIconFont(1024, centres)).toEqual({ ascent: 0.9375, descent: 0.0625 });
|
||||
});
|
||||
|
||||
it("declines a font it cannot read a box out of", () => {
|
||||
expect(measureIconFont(0, pack(448))).toBeNull();
|
||||
expect(measureIconFont(-1024, pack(448))).toBeNull();
|
||||
expect(measureIconFont(1024, [])).toBeNull();
|
||||
expect(measureIconFont(1024, pack(448, 7))).toBeNull();
|
||||
|
||||
// Ink so far from the em that the overrides would come out negative.
|
||||
expect(measureIconFont(1024, pack(-900))).toBeNull();
|
||||
expect(measureIconFont(1024, pack(2600))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconFontFaceOverrides", () => {
|
||||
it("writes the descriptors a font face carries", () => {
|
||||
expect(iconFontFaceOverrides({ ascent: 0.9375, descent: 0.0625 })).toEqual([
|
||||
"ascent-override: 93.75%;",
|
||||
"descent-override: 6.25%;",
|
||||
"line-gap-override: 0%;"
|
||||
]);
|
||||
});
|
||||
|
||||
it("declares nothing for a pack that gave no usable metrics", () => {
|
||||
expect(iconFontFaceOverrides(undefined)).toEqual([]);
|
||||
expect(iconFontFaceOverrides({ ascent: 0.9, descent: NaN })).toEqual([]);
|
||||
expect(iconFontFaceOverrides({ ascent: -0.1, descent: 0.1 })).toEqual([]);
|
||||
expect(iconFontFaceOverrides({ ascent: 4, descent: 0.1 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a manifest value that is not a number, which a pack's author writes by hand", () => {
|
||||
const evil = { ascent: "0%; } body { display: none } @font-face { x: 1", descent: 0.1 };
|
||||
|
||||
expect(iconFontFaceOverrides(evil as never)).toEqual([]);
|
||||
});
|
||||
});
|
||||
96
packages/commons/src/lib/icon_font_metrics.ts
Normal file
96
packages/commons/src/lib/icon_font_metrics.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Where an icon pack draws its glyphs, as fractions of the em.
|
||||
*
|
||||
* A browser takes the box it centres a glyph in from the font's platform metrics, which carry
|
||||
* padding the pack never drew in: Boxicons 3 declares 300 units per em and a Windows ascent of 327,
|
||||
* so each of its icons sits about 4.5% of the em below the middle of its container. Declaring the
|
||||
* pack's own box through `ascent-override` and `descent-override` removes that, and pins the result
|
||||
* across platforms that read different metrics out of the same file.
|
||||
*/
|
||||
export interface IconFontMetrics {
|
||||
/** The `ascent-override` value. */
|
||||
ascent: number;
|
||||
/** The `descent-override` value. */
|
||||
descent: number;
|
||||
}
|
||||
|
||||
/** Below this many inked glyphs the middle of the set says too little to override anything by. */
|
||||
const MIN_MEASURED_GLYPHS = 8;
|
||||
|
||||
/** The largest override that can be meant seriously, as a fraction of the em. */
|
||||
const MAX_OVERRIDE = 2;
|
||||
|
||||
/**
|
||||
* The metrics of a pack, worked out from where it draws.
|
||||
*
|
||||
* The middle of the ink is taken over the whole pack rather than per glyph: individual icons are
|
||||
* drawn off centre on purpose, and the median says where the box they share sits without following
|
||||
* any of them.
|
||||
*
|
||||
* @param unitsPerEm the font's own unit scale.
|
||||
* @param inkCentres the vertical middle of each glyph's ink, in font units above the baseline.
|
||||
* @returns the metrics, or `null` where the font says too little to correct.
|
||||
*/
|
||||
export function measureIconFont(unitsPerEm: number, inkCentres: number[]): IconFontMetrics | null {
|
||||
if (!(unitsPerEm > 0) || inkCentres.length < MIN_MEASURED_GLYPHS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const middle = median(inkCentres) / unitsPerEm;
|
||||
const ascent = 0.5 + middle;
|
||||
const descent = 0.5 - middle;
|
||||
if (!isOverride(ascent) || !isOverride(descent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ascent: roundFraction(ascent), descent: roundFraction(descent) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `@font-face` descriptors a pack's metrics stand for, one per line, or nothing where it
|
||||
* declares none.
|
||||
*
|
||||
* A pack's manifest is written by whoever made it, so a value that is not a usable number is
|
||||
* dropped rather than passed on to the stylesheet.
|
||||
*/
|
||||
export function iconFontFaceOverrides(metrics: IconFontMetrics | undefined): string[] {
|
||||
const ascent = overridePercentage(metrics?.ascent);
|
||||
const descent = overridePercentage(metrics?.descent);
|
||||
if (ascent === null || descent === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
`ascent-override: ${ascent}%;`,
|
||||
`descent-override: ${descent}%;`,
|
||||
"line-gap-override: 0%;"
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether a computed override is a number a `@font-face` can carry. */
|
||||
function isOverride(value: number) {
|
||||
return Number.isFinite(value) && value >= 0 && value <= MAX_OVERRIDE;
|
||||
}
|
||||
|
||||
/** One override as a percentage, or `null` where the manifest's value cannot be used. */
|
||||
function overridePercentage(value: number | undefined) {
|
||||
if (typeof value !== "number" || !isOverride(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number((value * 100).toFixed(2));
|
||||
}
|
||||
|
||||
/** Six decimals of the em, which is finer than any screen resolves. */
|
||||
function roundFraction(value: number) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
const sorted = [ ...values ].sort((first, second) => first - second);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
{
|
||||
"metrics": {
|
||||
"ascent": 0.9375,
|
||||
"descent": 0.0625
|
||||
},
|
||||
"icons": {
|
||||
"bx-child": {
|
||||
"glyph": "",
|
||||
|
||||
@ -164,6 +164,39 @@ describe("CSS generation", () => {
|
||||
expect(css).toContain(`.bx.bx-ball::before { content: "\ue9c2"; }`);
|
||||
expect(css).toContain(`.bx.bxs-party::before { content: "\uec92"; }`);
|
||||
});
|
||||
|
||||
it("declares the box a pack was measured in, and nothing where it was not", () => {
|
||||
const measured = cssForManifest({ ...manifest, metrics: { ascent: 0.875, descent: 0.125 } });
|
||||
expect(measured).toContain("ascent-override: 87.5%;");
|
||||
expect(measured).toContain("descent-override: 12.5%;");
|
||||
expect(measured).toContain("line-gap-override: 0%;");
|
||||
|
||||
const unmeasured = cssForManifest(manifest);
|
||||
expect(unmeasured).toContain("@font-face");
|
||||
expect(unmeasured).not.toContain("ascent-override");
|
||||
expect(unmeasured).not.toContain("descent-override");
|
||||
});
|
||||
|
||||
it("drops metrics a pack's author wrote by hand and got wrong", () => {
|
||||
const evil = "0%; } body { display: none } @font-face { a: 1";
|
||||
|
||||
const css = cssForManifest({ ...manifest, metrics: { ascent: evil, descent: 0.1 } as never });
|
||||
expect(css).not.toContain("ascent-override");
|
||||
expect(css).not.toContain("display: none");
|
||||
});
|
||||
|
||||
function cssForManifest(iconPackManifest: IconPackManifest) {
|
||||
const processed = processIconPack(buildNote({
|
||||
type: "text",
|
||||
title: "Boxicons v2",
|
||||
content: JSON.stringify(iconPackManifest),
|
||||
attachments: [ defaultAttachment ],
|
||||
"#iconPack": "bx"
|
||||
}));
|
||||
expect(processed).toBeTruthy();
|
||||
|
||||
return (processed && generateCss(processed, "/api/attachments/x/download")) ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
describe("Generating CSS for untrusted manifests", () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { IconRegistry } from "@triliumnext/commons";
|
||||
import { iconFontFaceOverrides, type IconFontMetrics, IconRegistry } from "@triliumnext/commons";
|
||||
|
||||
import type BAttachment from "../becca/entities/battachment";
|
||||
import type BNote from "../becca/entities/bnote";
|
||||
@ -38,6 +38,12 @@ export interface IconPackManifest {
|
||||
glyph: string,
|
||||
terms: string[];
|
||||
}>;
|
||||
/**
|
||||
* Where the pack draws its glyphs, so that a browser centres them on the box they were drawn in
|
||||
* rather than on the one the font's platform metrics describe. Packs built before this was
|
||||
* measured carry none, and are left to the browser.
|
||||
*/
|
||||
metrics?: IconFontMetrics;
|
||||
}
|
||||
|
||||
export interface ProcessedIconPack {
|
||||
@ -174,12 +180,17 @@ export function generateCss({ manifest, fontMime, builtin, fontAttachmentId, pre
|
||||
}
|
||||
|
||||
const fontFamily = builtin ? fontAttachmentId : `trilium-icon-pack-${prefix}`;
|
||||
const fontFace = [
|
||||
`font-family: '${fontFamily}';`,
|
||||
"font-weight: normal;",
|
||||
"font-style: normal;",
|
||||
`src: url('${fontUrl}') format('${MIME_TO_CSS_FORMAT_MAPPINGS[fontMime]}');`,
|
||||
...iconFontFaceOverrides(manifest.metrics)
|
||||
].join("\n ");
|
||||
|
||||
return `\
|
||||
@font-face {
|
||||
font-family: '${fontFamily}';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
src: url('${fontUrl}') format('${MIME_TO_CSS_FORMAT_MAPPINGS[fontMime]}');
|
||||
${fontFace}
|
||||
}
|
||||
|
||||
.${prefix} {
|
||||
|
||||
@ -558,6 +558,10 @@ importers:
|
||||
version: 44.2.0(supports-color@10.2.2)
|
||||
|
||||
apps/icon-pack-builder:
|
||||
dependencies:
|
||||
'@triliumnext/commons':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/commons
|
||||
devDependencies:
|
||||
'@mdi/font':
|
||||
specifier: 7.4.47
|
||||
@ -565,6 +569,9 @@ importers:
|
||||
'@phosphor-icons/web':
|
||||
specifier: 2.1.2
|
||||
version: 2.1.2
|
||||
opentype.js:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
|
||||
apps/mobile:
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user