Merge pull request #1549 from TriliumNext/feature/touchbar

Basic touchbar integration
This commit is contained in:
Elian Doran 2025-04-13 23:42:51 +03:00 committed by GitHub
commit e79730a707
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 435 additions and 14 deletions

View File

@ -3,7 +3,7 @@ import bundleService from "../services/bundle.js";
import RootCommandExecutor from "./root_command_executor.js";
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js";
import options from "../services/options.js";
import utils from "../services/utils.js";
import utils, { hasTouchBar } from "../services/utils.js";
import zoomComponent from "./zoom.js";
import TabManager from "./tab_manager.js";
import Component from "./component.js";
@ -24,7 +24,8 @@ import type NoteTreeWidget from "../widgets/note_tree.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
import type FAttribute from "../entities/fattribute.js";
import type { NativeImage, TouchBar } from "electron";
import TouchBarComponent from "./touch_bar.js";
interface Layout {
getRootWidget: (appContext: AppContext) => RootWidget;
@ -170,6 +171,8 @@ export type CommandMappings = {
moveNoteDownInHierarchy: ContextMenuCommandData;
selectAllNotesInParent: ContextMenuCommandData;
createNoteIntoInbox: CommandData;
addNoteLauncher: ContextMenuCommandData;
addScriptLauncher: ContextMenuCommandData;
addWidgetLauncher: ContextMenuCommandData;
@ -249,6 +252,7 @@ export type CommandMappings = {
scrollToEnd: CommandData;
closeThisNoteSplit: CommandData;
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
jumpToNote: CommandData;
// Geomap
deleteFromMap: { noteId: string };
@ -263,6 +267,14 @@ export type CommandMappings = {
refreshResults: {};
refreshSearchDefinition: {};
geoMapCreateChildNote: CommandData;
buildTouchBar: CommandData & {
TouchBar: typeof TouchBar;
buildIcon(name: string): NativeImage;
};
refreshTouchBar: CommandData;
};
type EventMappings = {
@ -467,6 +479,10 @@ export class AppContext extends Component {
if (utils.isElectron()) {
this.child(zoomComponent);
}
if (hasTouchBar) {
this.child(new TouchBarComponent());
}
}
renderWidgets() {

View File

@ -0,0 +1,135 @@
import utils from "../services/utils.js";
import Component from "./component.js";
import appContext from "./app_context.js";
import type { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl, TouchBarSpacer } from "@electron/remote";
export type TouchBarItem = (TouchBarButton | TouchBarSpacer | TouchBarGroup | TouchBarSegmentedControl);
export function buildSelectedBackgroundColor(isSelected: boolean) {
return isSelected ? "#757575" : undefined;
}
export default class TouchBarComponent extends Component {
nativeImage: typeof import("electron").nativeImage;
remote: typeof import("@electron/remote");
lastFocusedComponent?: Component;
private $activeModal?: JQuery<HTMLElement>;
constructor() {
super();
this.nativeImage = utils.dynamicRequire("electron").nativeImage;
this.remote = utils.dynamicRequire("@electron/remote") as typeof import("@electron/remote");
this.$widget = $("<div>");
$(window).on("focusin", async (e) => {
const $target = $(e.target);
this.$activeModal = $target.closest(".modal-dialog");
const parentComponentEl = $target.closest(".component");
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
this.#refreshTouchBar();
});
}
buildIcon(name: string) {
const sourceImage = this.nativeImage.createFromNamedImage(name, [-1, 0, 1]);
const { width, height } = sourceImage.getSize();
const newImage = this.nativeImage.createEmpty();
newImage.addRepresentation({
scaleFactor: 1,
width: width / 2,
height: height / 2,
buffer: sourceImage.resize({ height: height / 2 }).toBitmap()
});
newImage.addRepresentation({
scaleFactor: 2,
width: width,
height: height,
buffer: sourceImage.toBitmap()
});
return newImage;
}
#refreshTouchBar() {
const { TouchBar } = this.remote;
const parentComponent = this.lastFocusedComponent;
let touchBar = null;
if (this.$activeModal?.length) {
touchBar = this.#buildModalTouchBar();
} else if (parentComponent) {
const items = parentComponent.triggerCommand("buildTouchBar", {
TouchBar,
buildIcon: this.buildIcon.bind(this)
}) as unknown as TouchBarItem[];
touchBar = this.#buildTouchBar(items);
}
if (touchBar) {
this.remote.getCurrentWindow().setTouchBar(touchBar);
}
}
#buildModalTouchBar() {
const { TouchBar } = this.remote;
const { TouchBarButton, TouchBarLabel, TouchBarSpacer } = this.remote.TouchBar;
const items: TouchBarItem[] = [];
// Look for the modal title.
const $title = this.$activeModal?.find(".modal-title");
if ($title?.length) {
items.push(new TouchBarLabel({ label: $title.text() }))
}
items.push(new TouchBarSpacer({ size: "flexible" }));
// Look for buttons in the modal.
const $buttons = this.$activeModal?.find(".modal-footer button");
for (const button of $buttons ?? []) {
items.push(new TouchBarButton({
label: button.innerText,
click: () => button.click(),
enabled: !button.hasAttribute("disabled")
}));
}
items.push(new TouchBarSpacer({ size: "flexible" }));
return new TouchBar({ items });
}
#buildTouchBar(componentSpecificItems?: TouchBarItem[]) {
const { TouchBar } = this.remote;
const { TouchBarButton, TouchBarSpacer, TouchBarGroup, TouchBarSegmentedControl, TouchBarOtherItemsProxy } = this.remote.TouchBar;
// Disregard recursive calls or empty results.
if (!componentSpecificItems || "then" in componentSpecificItems) {
componentSpecificItems = [];
}
const items = [
new TouchBarButton({
icon: this.buildIcon("NSTouchBarComposeTemplate"),
click: () => this.triggerCommand("createNoteIntoInbox")
}),
new TouchBarSpacer({ size: "small" }),
...componentSpecificItems,
new TouchBarSpacer({ size: "flexible" }),
new TouchBarOtherItemsProxy(),
new TouchBarButton({
icon: this.buildIcon("NSTouchBarAddDetailTemplate"),
click: () => this.triggerCommand("jumpToNote")
})
].flat();
console.log("Update ", items);
return new TouchBar({
items
});
}
refreshTouchBarEvent() {
this.#refreshTouchBar();
}
}

View File

@ -83,7 +83,7 @@ import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_ref
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
import options from "../services/options.js";
import utils from "../services/utils.js";
import utils, { hasTouchBar } from "../services/utils.js";
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
import CloseZenButton from "../widgets/close_zen_button.js";

View File

@ -147,6 +147,8 @@ function isMac() {
return navigator.platform.indexOf("Mac") > -1;
}
export const hasTouchBar = (isMac() && isElectron());
function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement>) {
return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey);
}

View File

@ -339,6 +339,11 @@ declare global {
mention: MentionConfig
});
enableReadOnlyMode(reason: string);
commands: {
get(name: string): {
value: unknown;
};
}
model: {
document: {
on(event: string, cb: () => void);

View File

@ -23,7 +23,6 @@ export default class EditButton extends OnClickButtonWidget {
this.noteContext.viewScope.readOnlyTemporarilyDisabled = true;
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext: this.noteContext });
}
this.refresh();
});
}
@ -68,6 +67,10 @@ export default class EditButton extends OnClickButtonWidget {
}
}
readOnlyTemporarilyDisabledEvent() {
this.refresh();
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();

View File

@ -1,7 +1,7 @@
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import NoteListRenderer from "../services/note_list_renderer.js";
import type FNote from "../entities/fnote.js";
import type { EventData } from "../components/app_context.js";
import type { CommandListener, CommandListenerData, EventData } from "../components/app_context.js";
import type ViewMode from "./view_widgets/view_mode.js";
const TPL = /*html*/`
@ -127,4 +127,11 @@ export default class NoteListWidget extends NoteContextAwareWidget {
this.checkRenderStatus();
}
}
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
if (this.viewMode && "buildTouchBarCommand" in this.viewMode) {
return (this.viewMode as CommandListener<"buildTouchBar">).buildTouchBarCommand(data);
}
}
}

View File

@ -25,6 +25,8 @@ import type FNote from "../entities/fnote.js";
import type { NoteType } from "../entities/fnote.js";
import type { AttributeRow, BranchRow } from "../services/load_results.js";
import type { SetNoteOpts } from "../components/note_context.js";
import type { TouchBarItem } from "../components/touch_bar.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
const TPL = /*html*/`
<div class="tree-wrapper">
@ -1763,4 +1765,38 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
appContext.tabManager.getActiveContext()?.setNote(resp.note.noteId);
}
buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) {
const triggerCommand = (command: TreeCommandNames) => {
const node = this.getActiveNode();
const notePath = treeService.getNotePath(node);
this.triggerCommand<TreeCommandNames>(command, {
node,
notePath,
noteId: node.data.noteId,
selectedOrActiveBranchIds: this.getSelectedOrActiveBranchIds(node),
selectedOrActiveNoteIds: this.getSelectedOrActiveNoteIds(node)
});
}
const items: TouchBarItem[] = [
new TouchBar.TouchBarButton({
icon: buildIcon("NSImageNameTouchBarAddTemplate"),
click: () => {
const node = this.getActiveNode();
const notePath = treeService.getNotePath(node);
noteCreateService.createNote(notePath, {
isProtected: node.data.isProtected
});
}
}),
new TouchBar.TouchBarButton({
icon: buildIcon("NSImageNameTouchBarDeleteTemplate"),
click: () => triggerCommand("deleteNotes")
})
];
return items;
}
}

View File

@ -1,9 +1,12 @@
import type { EventData } from "../../components/app_context.js";
import type { CommandListenerData, EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
import { t } from "../../services/i18n.js";
import keyboardActionService from "../../services/keyboard_actions.js";
import options from "../../services/options.js";
import AbstractCodeTypeWidget from "./abstract_code_type_widget.js";
import appContext from "../../components/app_context.js";
import type { TouchBarItem } from "../../components/touch_bar.js";
import { hasTouchBar } from "../../services/utils.js";
const TPL = /*html*/`
<div class="note-detail-code note-detail-printable">
@ -61,6 +64,10 @@ export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
});
this.show();
if (this.parent && hasTouchBar) {
this.triggerCommand("refreshTouchBar");
}
}
getData() {
@ -78,4 +85,19 @@ export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
resolve(this.codeEditor);
}
buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) {
const items: TouchBarItem[] = [];
const note = this.note;
if (note?.mime.startsWith("application/javascript") || note?.mime === "text/x-sqlite;schema=trilium") {
items.push(new TouchBar.TouchBarButton({
icon: buildIcon("NSImageNameTouchBarPlayTemplate"),
click: () => appContext.triggerCommand("runActiveNote")
}));
}
return items;
}
}

View File

@ -2,18 +2,19 @@ import { t } from "../../services/i18n.js";
import libraryLoader from "../../services/library_loader.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import mimeTypesService from "../../services/mime_types.js";
import utils from "../../services/utils.js";
import utils, { hasTouchBar } from "../../services/utils.js";
import keyboardActionService from "../../services/keyboard_actions.js";
import froca from "../../services/froca.js";
import noteCreateService from "../../services/note_create.js";
import AbstractTextTypeWidget from "./abstract_text_type_widget.js";
import link from "../../services/link.js";
import appContext, { type EventData } from "../../components/app_context.js";
import appContext, { type CommandListenerData, type EventData } from "../../components/app_context.js";
import dialogService from "../../services/dialog.js";
import { initSyntaxHighlighting } from "./ckeditor/syntax_highlight.js";
import options from "../../services/options.js";
import toast from "../../services/toast.js";
import { normalizeMimeTypeForCKEditor } from "../../services/mime_type_definitions.js";
import { buildSelectedBackgroundColor } from "../../components/touch_bar.js";
import { buildConfig, buildToolbarConfig } from "./ckeditor/config.js";
import type FNote from "../../entities/fnote.js";
import { getMermaidConfig } from "../../services/mermaid.js";
@ -280,6 +281,13 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
CKEditorInspector.attach(editor);
}
// Touch bar integration
if (hasTouchBar) {
for (const event of [ "bold", "italic", "underline", "paragraph", "heading" ]) {
editor.commands.get(event).on("change", () => this.triggerCommand("refreshTouchBar"));
}
}
return editor;
});
@ -544,4 +552,60 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
await this.reinitialize(data);
}
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
const { TouchBar, buildIcon } = data;
const { TouchBarSegmentedControl, TouchBarGroup, TouchBarButton } = TouchBar;
const { editor } = this.watchdog;
const commandButton = (icon: string, command: string) => new TouchBarButton({
icon: buildIcon(icon),
click: () => editor.execute(command),
backgroundColor: buildSelectedBackgroundColor(editor.commands.get(command).value as boolean)
});
let headingSelectedIndex = undefined;
const headingCommand = editor.commands.get("heading");
const paragraphCommand = editor.commands.get("paragraph");
if (paragraphCommand.value) {
headingSelectedIndex = 0;
} else if (headingCommand.value === "heading2") {
headingSelectedIndex = 1;
} else if (headingCommand.value === "heading3") {
headingSelectedIndex = 2;
}
return [
new TouchBarSegmentedControl({
segments: [
{ label: "P" },
{ label: "H2" },
{ label: "H3" }
],
change(selectedIndex, isSelected) {
switch (selectedIndex) {
case 0:
editor.execute("paragraph")
break;
case 1:
editor.execute("heading", { value: "heading2" });
break;
case 2:
editor.execute("heading", { value: "heading3" });
break;
}
},
selectedIndex: headingSelectedIndex
}),
new TouchBarGroup({
items: new TouchBar({
items: [
commandButton("NSTouchBarTextBoldTemplate", "bold"),
commandButton("NSTouchBarTextItalicTemplate", "italic"),
commandButton("NSTouchBarTextUnderlineTemplate", "underline")
]
})
})
];
}
}

View File

@ -5,7 +5,7 @@ import TypeWidget from "./type_widget.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import dialogService from "../../services/dialog.js";
import type { EventData } from "../../components/app_context.js";
import type { CommandListenerData, EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import attributes from "../../services/attributes.js";
import openContextMenu from "./geo_map_context_menu.js";
@ -15,6 +15,7 @@ import appContext from "../../components/app_context.js";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIconShadow from "leaflet/dist/images/marker-shadow.png";
import { hasTouchBar } from "../../services/utils.js";
const TPL = /*html*/`\
<div class="note-detail-geo-map note-detail-printable">
@ -107,6 +108,7 @@ export default class GeoMapTypeWidget extends TypeWidget {
private currentMarkerData: Record<string, Marker>;
private currentTrackData: Record<string, GPX>;
private gpxLoaded?: boolean;
private ignoreNextZoomEvent?: boolean;
static getType() {
return "geoMap";
@ -151,6 +153,16 @@ export default class GeoMapTypeWidget extends TypeWidget {
map.on("moveend", updateFn);
map.on("zoomend", updateFn);
map.on("click", (e) => this.#onMapClicked(e));
if (hasTouchBar) {
map.on("zoom", () => {
if (!this.ignoreNextZoomEvent) {
this.triggerCommand("refreshTouchBar");
}
this.ignoreNextZoomEvent = false;
});
}
}
async #restoreViewportAndZoom() {
@ -279,6 +291,9 @@ export default class GeoMapTypeWidget extends TypeWidget {
#changeState(newState: State) {
this._state = newState;
this.geoMapWidget.$container.toggleClass("placing-note", newState === State.NewNote);
if (hasTouchBar) {
this.triggerCommand("refreshTouchBar");
}
}
async #onMapClicked(e: LeafletMouseEvent) {
@ -388,4 +403,30 @@ export default class GeoMapTypeWidget extends TypeWidget {
this.moveMarker(noteId, null);
}
buildTouchBarCommand({ TouchBar }: CommandListenerData<"buildTouchBar">) {
const map = this.geoMapWidget.map;
const that = this;
if (!map) {
return;
}
return [
new TouchBar.TouchBarSlider({
label: "Zoom",
value: map.getZoom(),
minValue: map.getMinZoom(),
maxValue: map.getMaxZoom(),
change(newValue) {
that.ignoreNextZoomEvent = true;
map.setZoom(newValue);
},
}),
new TouchBar.TouchBarButton({
label: "New geo note",
click: () => this.triggerCommand("geoMapCreateChildNote", { ntxId: this.ntxId }),
enabled: (this._state === State.Normal)
})
];
}
}

View File

@ -2,12 +2,13 @@ import AbstractTextTypeWidget from "./abstract_text_type_widget.js";
import libraryLoader from "../../services/library_loader.js";
import { applySyntaxHighlight } from "../../services/syntax_highlight.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
import type { CommandListenerData, EventData } from "../../components/app_context.js";
import { getLocaleById } from "../../services/i18n.js";
import appContext from "../../components/app_context.js";
import { getMermaidConfig } from "../../services/mermaid.js";
const TPL = /*html*/`
<div class="note-detail-readonly-text note-detail-printable">
<div class="note-detail-readonly-text note-detail-printable" tabindex="100">
<style>
/* h1 should not be used at all since semantically that's a note title */
.note-detail-readonly-text h1 { font-size: 1.8em; }
@ -169,4 +170,20 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
this.$widget.attr("dir", isRtl ? "rtl" : "ltr");
}
buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) {
return [
new TouchBar.TouchBarSpacer({ size: "flexible" }),
new TouchBar.TouchBarButton({
icon: buildIcon("NSLockUnlockedTemplate"),
click: () => {
if (this.noteContext?.viewScope) {
this.noteContext.viewScope.readOnlyTemporarilyDisabled = true;
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext: this.noteContext });
}
this.refresh();
}
})
];
}
}

View File

@ -7,12 +7,14 @@ import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import dialogService from "../../services/dialog.js";
import attributes from "../../services/attributes.js";
import type { EventData } from "../../components/app_context.js";
import utils from "../../services/utils.js";
import type { CommandListenerData, EventData } from "../../components/app_context.js";
import utils, { hasTouchBar } from "../../services/utils.js";
import date_notes from "../../services/date_notes.js";
import appContext from "../../components/app_context.js";
import type { EventImpl } from "@fullcalendar/core/internal";
import debounce, { type DebouncedFunction } from "debounce";
import type { TouchBarItem } from "../../components/touch_bar.js";
import type { SegmentedControlSegment } from "electron";
const TPL = /*html*/`
<div class="calendar-view">
@ -69,7 +71,7 @@ const TPL = /*html*/`
}
</style>
<div class="calendar-container">
<div class="calendar-container" tabindex="100">
</div>
</div>
`;
@ -266,6 +268,10 @@ export default class CalendarView extends ViewMode {
this.debouncedSaveView();
this.lastView = currentView;
if (hasTouchBar) {
appContext.triggerCommand("refreshTouchBar");
}
}
async #onCalendarSelection(e: DateSelectArg) {
@ -595,4 +601,71 @@ export default class CalendarView extends ViewMode {
return newDate;
}
buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) {
if (!this.calendar) {
return;
}
const items: TouchBarItem[] = [];
const $toolbarItems = this.$calendarContainer.find(".fc-toolbar-chunk .fc-button-group, .fc-toolbar-chunk > button");
for (const item of $toolbarItems) {
// Button groups.
if (item.classList.contains("fc-button-group")) {
let mode: "single" | "buttons" = "single";
let selectedIndex = 0;
const segments: SegmentedControlSegment[] = [];
const subItems = item.childNodes as NodeListOf<HTMLElement>;
let index = 0;
for (const subItem of subItems) {
if (subItem.ariaPressed === "true") {
selectedIndex = index;
}
index++;
// Text button.
if (subItem.innerText) {
segments.push({ label: subItem.innerText });
continue;
}
// Icon button.
const iconEl = subItem.querySelector("span.fc-icon");
let icon = null;
if (iconEl?.classList.contains("fc-icon-chevron-left")) {
icon = "NSImageNameTouchBarGoBackTemplate";
mode = "buttons";
} else if (iconEl?.classList.contains("fc-icon-chevron-right")) {
icon = "NSImageNameTouchBarGoForwardTemplate";
mode = "buttons";
}
if (icon) {
segments.push({
icon: buildIcon(icon)
});
}
}
items.push(new TouchBar.TouchBarSegmentedControl({
mode,
segments,
selectedIndex,
change: (selectedIndex, isSelected) => subItems[selectedIndex].click()
}));
continue;
}
// Standalone item.
if (item.innerText) {
items.push(new TouchBar.TouchBarButton({
label: item.innerText,
click: () => item.click()
}));
}
}
return items;
}
}