Port Quick edit popup to React (#7840)
Some checks are pending
Checks / main (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Dev / Test development (push) Waiting to run
Dev / Build Docker image (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile) (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile.alpine) (push) Blocked by required conditions
/ Check Docker build (Dockerfile) (push) Waiting to run
/ Check Docker build (Dockerfile.alpine) (push) Waiting to run
/ Build Docker images (Dockerfile, ubuntu-24.04-arm, linux/arm64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.alpine, ubuntu-latest, linux/amd64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v7) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v8) (push) Blocked by required conditions
/ Merge manifest lists (push) Blocked by required conditions
playwright / E2E tests on linux-arm64 (push) Waiting to run
playwright / E2E tests on linux-x64 (push) Waiting to run

This commit is contained in:
Elian Doran 2025-11-22 21:53:19 +02:00 committed by GitHub
commit 2d8b1299b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 175 additions and 211 deletions

View File

@ -22,16 +22,8 @@ import RevisionsDialog from "../widgets/dialogs/revisions.js";
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
import InfoDialog from "../widgets/dialogs/info.js";
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
import FlexContainer from "../widgets/containers/flex_container.js";
import NoteIconWidget from "../widgets/note_icon";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@ -57,16 +49,6 @@ export function applyModals(rootContainer: RootContainer) {
.child(<ConfirmDialog />)
.child(<PromptDialog />)
.child(<IncorrectCpuArchDialog />)
.child(new PopupEditorDialog()
.child(new FlexContainer("row")
.class("title-row")
.css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />))
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
.child(new PromotedAttributesWidget())
.child(<NoteDetail />)
.child(<NoteList media="screen" displayOnlyCollections />))
.child(<PopupEditorDialog />)
.child(<CallToActionDialog />);
}

View File

@ -2591,7 +2591,7 @@ iframe.print-iframe {
flex-direction: column;
}
.scrolling-container > .note-detail.full-height,
.note-detail.full-height,
.scrolling-container > .note-list-widget.full-height {
position: relative;
flex-grow: 1;

View File

@ -0,0 +1,64 @@
/** Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. */
body.popup-editor-open > .modal-backdrop { z-index: 998; }
body.popup-editor-open .popup-editor-dialog { z-index: 999; }
body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; }
body.desktop .modal.popup-editor-dialog .modal-dialog {
max-width: 75vw;
}
.modal.popup-editor-dialog .modal-header .modal-title {
font-size: 1.1em;
}
.modal.popup-editor-dialog .modal-header .title-row {
flex-grow: 1;
display: flex;
align-items: center;
}
.modal.popup-editor-dialog .modal-header .title-row > * {
margin: 5px;
}
.modal.popup-editor-dialog .modal-body {
padding: 0;
height: 75vh;
overflow: auto;
display: flex;
flex-direction: column;
}
.modal.popup-editor-dialog .note-detail-editable-text {
padding: 0 1em;
}
.modal.popup-editor-dialog .title-row,
.modal.popup-editor-dialog .modal-title,
.modal.popup-editor-dialog .note-icon-widget {
height: 32px;
}
.modal.popup-editor-dialog .note-icon-widget {
width: 32px;
margin: 0;
padding: 0;
}
.modal.popup-editor-dialog .note-icon-widget button.note-icon,
.modal.popup-editor-dialog .note-title-widget input.note-title {
font-size: 1em;
}
.modal.popup-editor-dialog .classic-toolbar-widget {
position: sticky;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background: var(--modal-background-color);
z-index: 998;
}
.modal.popup-editor-dialog .note-detail-file {
padding: 0;
}

View File

@ -0,0 +1,85 @@
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import Modal from "../react/Modal";
import "./PopupEditor.css";
import { useNoteContext, useTriliumEvent } from "../react/hooks";
import NoteTitleWidget from "../note_title";
import NoteIcon from "../note_icon";
import NoteContext from "../../components/note_context";
import { NoteContextContext, ParentComponent } from "../react/react_utils";
import NoteDetail from "../NoteDetail";
import { ComponentChildren } from "preact";
import NoteList from "../collections/NoteList";
import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter";
import FormattingToolbar from "../ribbon/FormattingToolbar";
export default function PopupEditor() {
const [ shown, setShown ] = useState(false);
const parentComponent = useContext(ParentComponent);
const [ noteContext, setNoteContext ] = useState(new NoteContext("_popup-editor"));
useTriliumEvent("openInPopup", async ({ noteIdOrPath }) => {
const noteContext = new NoteContext("_popup-editor");
await noteContext.setNote(noteIdOrPath, {
viewScope: {
readOnlyTemporarilyDisabled: true
}
});
setNoteContext(noteContext);
setShown(true);
});
// Add a global class to be able to handle issues with z-index due to rendering in a popup.
useEffect(() => {
document.body.classList.toggle("popup-editor-open", shown);
}, [shown]);
return (
<NoteContextContext.Provider value={noteContext}>
<DialogWrapper>
<Modal
title={<TitleRow />}
className="popup-editor-dialog"
size="lg"
show={shown}
onShown={() => {
parentComponent?.handleEvent("focusOnDetail", { ntxId: noteContext.ntxId });
}}
onHidden={() => setShown(false)}
>
<StandaloneRibbonAdapter component={FormattingToolbar} />
<NoteDetail />
<NoteList media="screen" displayOnlyCollections />
</Modal>
</DialogWrapper>
</NoteContextContext.Provider>
)
}
export function DialogWrapper({ children }: { children: ComponentChildren }) {
const { note } = useNoteContext();
const wrapperRef = useRef<HTMLDivElement>(null);
const [ hasTint, setHasTint ] = useState(false);
// Apply the tinted-dialog class only if the custom color CSS class specifies a hue
useEffect(() => {
if (!wrapperRef.current) return;
const customHue = getComputedStyle(wrapperRef.current).getPropertyValue("--custom-color-hue");
setHasTint(!!customHue);
}, [ note ]);
return (
<div ref={wrapperRef} class={`quick-edit-dialog-wrapper ${note?.getColorClass() ?? ""} ${hasTint ? "tinted-quick-edit-dialog" : ""}`}>
{children}
</div>
)
}
export function TitleRow() {
return (
<div className="title-row">
<NoteIcon />
<NoteTitleWidget />
</div>
)
}

View File

@ -1,187 +0,0 @@
import type { EventNames, EventData } from "../../components/app_context.js";
import NoteContext from "../../components/note_context.js";
import { openDialog } from "../../services/dialog.js";
import BasicWidget, { ReactWrappedWidget } from "../basic_widget.js";
import Container from "../containers/container.js";
const TPL = /*html*/`\
<div class="popup-editor-dialog modal fade mx-auto" tabindex="-1" role="dialog">
<style>
/** Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. */
body.popup-editor-open > .modal-backdrop { z-index: 998; }
body.popup-editor-open .popup-editor-dialog { z-index: 999; }
body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; }
body.desktop .modal.popup-editor-dialog .modal-dialog {
max-width: 75vw;
}
.modal.popup-editor-dialog .modal-header .modal-title {
font-size: 1.1em;
}
.modal.popup-editor-dialog .modal-body {
padding: 0;
height: 75vh;
overflow: auto;
}
.modal.popup-editor-dialog .note-detail-editable-text {
padding: 0 1em;
}
.modal.popup-editor-dialog .title-row,
.modal.popup-editor-dialog .modal-title,
.modal.popup-editor-dialog .note-icon-widget {
height: 32px;
}
.modal.popup-editor-dialog .note-icon-widget {
width: 32px;
margin: 0;
padding: 0;
}
.modal.popup-editor-dialog .note-icon-widget button.note-icon,
.modal.popup-editor-dialog .note-title-widget input.note-title {
font-size: 1em;
}
.modal.popup-editor-dialog .classic-toolbar-widget {
position: sticky;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background: var(--modal-background-color);
z-index: 998;
}
.modal.popup-editor-dialog .note-detail-file {
padding: 0;
}
</style>
<div class="quick-edit-dialog-wrapper">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">
<!-- This is where the first child will be injected -->
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- This is where all but the first child will be injected. -->
</div>
</div>
</div>
</div>
</div>
`;
export default class PopupEditorDialog extends Container<BasicWidget> {
private noteContext: NoteContext;
private $modalHeader!: JQuery<HTMLElement>;
private $modalBody!: JQuery<HTMLElement>;
private $wrapper!: JQuery<HTMLDivElement>;
constructor() {
super();
this.noteContext = new NoteContext("_popup-editor");
}
doRender() {
// This will populate this.$widget with the content of the children.
super.doRender();
// Now we wrap it in the modal.
const $newWidget = $(TPL);
this.$modalHeader = $newWidget.find(".modal-title");
this.$modalBody = $newWidget.find(".modal-body");
this.$wrapper = $newWidget.find(".quick-edit-dialog-wrapper");
const children = this.$widget.children();
this.$modalHeader.append(children[0]);
this.$modalBody.append(children.slice(1));
this.$widget = $newWidget;
this.setVisibility(false);
}
async openInPopupEvent({ noteIdOrPath }: EventData<"openInPopup">) {
const $dialog = await openDialog(this.$widget, false, {
focus: false
});
await this.noteContext.setNote(noteIdOrPath, {
viewScope: {
readOnlyTemporarilyDisabled: true
}
});
const colorClass = this.noteContext.note?.getColorClass();
const wrapperElement = this.$wrapper.get(0)!;
if (colorClass) {
wrapperElement.className = "quick-edit-dialog-wrapper " + colorClass;
} else {
wrapperElement.className = "quick-edit-dialog-wrapper";
}
const customHue = getComputedStyle(wrapperElement).getPropertyValue("--custom-color-hue");
if (customHue) {
/* Apply the tinted-dialog class only if the custom color CSS class specifies a hue */
wrapperElement.classList.add("tinted-quick-edit-dialog");
}
const activeEl = document.activeElement;
if (activeEl && "blur" in activeEl) {
(activeEl as HTMLElement).blur();
}
$dialog.on("shown.bs.modal", async () => {
await this.handleEventInChildren("activeContextChanged", { noteContext: this.noteContext });
this.setVisibility(true);
await this.handleEventInChildren("focusOnDetail", { ntxId: this.noteContext.ntxId });
});
$dialog.on("hidden.bs.modal", () => {
const $typeWidgetEl = $dialog.find(".note-detail-printable");
if ($typeWidgetEl.length) {
const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget;
typeWidget.cleanup();
}
this.setVisibility(false);
});
}
setVisibility(visible: boolean) {
const $bodyItems = this.$modalBody.find("> div");
if (visible) {
$bodyItems.fadeIn();
this.$modalHeader.children().show();
document.body.classList.add("popup-editor-open");
} else {
$bodyItems.hide();
this.$modalHeader.children().hide();
document.body.classList.remove("popup-editor-open");
}
}
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
// Avoid events related to the current tab interfere with our popup.
if (["noteSwitched", "noteSwitchedAndActivated", "exportAsPdf", "printActiveNote"].includes(name)) {
return Promise.resolve();
}
// Avoid not showing recent notes when creating a new empty tab.
if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") {
return Promise.resolve();
}
return super.handleEventInChildren(name, data);
}
}

View File

@ -2,7 +2,7 @@ import { CSSProperties } from "preact/compat";
import { DragData } from "../note_tree";
import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons";
import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ParentComponent, refToJQuerySelector } from "./react_utils";
import { NoteContextContext, ParentComponent, refToJQuerySelector } from "./react_utils";
import { RefObject, VNode } from "preact";
import { Tooltip } from "bootstrap";
import { ViewMode, ViewScope } from "../../services/link";
@ -257,18 +257,29 @@ export function useUniqueName(prefix?: string) {
}
export function useNoteContext() {
const [ noteContext, setNoteContext ] = useState<NoteContext>();
const noteContextContext = useContext(NoteContextContext);
const [ noteContext, setNoteContext ] = useState<NoteContext | undefined>(noteContextContext ?? undefined);
const [ notePath, setNotePath ] = useState<string | null | undefined>();
const [ note, setNote ] = useState<FNote | null | undefined>();
const [ , setViewScope ] = useState<ViewScope>();
const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly);
const [ refreshCounter, setRefreshCounter ] = useState(0);
useEffect(() => {
if (!noteContextContext) return;
setNoteContext(noteContextContext);
setNote(noteContextContext.note);
setNotePath(noteContextContext.notePath);
setViewScope(noteContextContext.viewScope);
setIsReadOnlyTemporarilyDisabled(noteContextContext?.viewScope?.readOnlyTemporarilyDisabled);
}, [ noteContextContext ]);
useEffect(() => {
setNote(noteContext?.note);
}, [ notePath ]);
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => {
if (noteContextContext) return;
setNoteContext(noteContext);
setNotePath(noteContext.notePath);
setViewScope(noteContext.viewScope);
@ -282,6 +293,7 @@ export function useNoteContext() {
}
});
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (noteContextContext) return;
if (eventNoteContext.ntxId === noteContext?.ntxId) {
setIsReadOnlyTemporarilyDisabled(eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled);
}

View File

@ -1,8 +1,11 @@
import { ComponentChild, createContext, render, type JSX, type RefObject } from "preact";
import Component from "../../components/component";
import NoteContext from "../../components/note_context";
export const ParentComponent = createContext<Component | null>(null);
export const NoteContextContext = createContext<NoteContext | null>(null);
/**
* Takes in a React ref and returns a corresponding JQuery selector.
*

View File

@ -163,7 +163,12 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
pan: zoomInstance.getPan(),
zoom: zoomInstance.getZoom()
}
try {
zoomInstance.destroy();
} catch (e) {
// Sometimes crashes with "Matrix is not invertible" which can cause havoc such as breaking the popup editor from ever showing up again.
console.warn(e);
}
};
}, [ svg ]);