import { useEffect, useRef, useState } from "preact/hooks" import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5"; import { t } from "../../../services/i18n"; import server from "../../../services/server"; import note_autocomplete, { Suggestion } from "../../../services/note_autocomplete"; import CKEditor from "../../react/CKEditor"; import { useLegacyWidget, useTooltip } from "../../react/hooks"; import FAttribute from "../../../entities/fattribute"; import attribute_renderer from "../../../services/attribute_renderer"; import FNote from "../../../entities/fnote"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; import attribute_parser, { Attribute } from "../../../services/attribute_parser"; import ActionButton from "../../react/ActionButton"; import { escapeQuotes } from "../../../services/utils"; const HELP_TEXT = `

${t("attribute_editor.help_text_body1")}

${t("attribute_editor.help_text_body2")}

${t("attribute_editor.help_text_body3")}

`; const mentionSetup: MentionFeed[] = [ { marker: "@", feed: (queryText) => note_autocomplete.autocompleteSourceForCKEditor(queryText), itemRenderer: (_item) => { const item = _item as Suggestion; const itemElement = document.createElement("button"); itemElement.innerHTML = `${item.highlightedNotePathTitle} `; return itemElement; }, minimumCharacters: 0 }, { marker: "#", feed: async (queryText) => { const names = await server.get(`attribute-names/?type=label&query=${encodeURIComponent(queryText)}`); return names.map((name) => { return { id: `#${name}`, name: name }; }); }, minimumCharacters: 0 }, { marker: "~", feed: async (queryText) => { const names = await server.get(`attribute-names/?type=relation&query=${encodeURIComponent(queryText)}`); return names.map((name) => { return { id: `~${name}`, name: name }; }); }, minimumCharacters: 0 } ]; export default function AttributeEditor({ note, componentId }: { note: FNote, componentId: string }) { const [ state, setState ] = useState<"normal" | "showHelpTooltip" | "showAttributeDetail">(); const [ error, setError ] = useState(); const [ needsSaving, setNeedsSaving ] = useState(false); const [ initialValue, setInitialValue ] = useState(""); const lastSavedContent = useRef(); const currentValueRef = useRef(initialValue); const wrapperRef = useRef(null); const { showTooltip, hideTooltip } = useTooltip(wrapperRef, { trigger: "focus", html: true, title: HELP_TEXT, placement: "bottom", offset: "0,30" }); const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget()); useEffect(() => { if (state === "showHelpTooltip") { showTooltip(); } else { hideTooltip(); } }, [ state ]); async function renderOwnedAttributes(ownedAttributes: FAttribute[], saved: boolean) { // attrs are not resorted if position changes after the initial load ownedAttributes.sort((a, b) => a.position - b.position); let htmlAttrs = getPreprocessedData("

" + (await attribute_renderer.renderAttributes(ownedAttributes, true)).html() + "

"); if (saved) { lastSavedContent.current = htmlAttrs; setNeedsSaving(false); } if (htmlAttrs.length > 0) { htmlAttrs += " "; } setInitialValue(htmlAttrs); } function parseAttributes() { try { return attribute_parser.lexAndParse(getPreprocessedData(currentValueRef.current)); } catch (e: any) { setError(e); } } async function save() { const attributes = parseAttributes(); if (!attributes) { // An error occurred and will be reported to the user. return; } await server.put(`notes/${note.noteId}/attributes`, attributes, componentId); setNeedsSaving(false); // blink the attribute text to give a visual hint that save has been executed if (wrapperRef.current) { wrapperRef.current.style.opacity = "0"; setTimeout(() => wrapperRef.current!.style.opacity = "1", 100); } } useEffect(() => { renderOwnedAttributes(note.getOwnedAttributes(), true); }, [ note ]); return ( <>
{ if (e.key === "Enter") { // allow autocomplete to fill the result textarea setTimeout(() => save(), 100); } }} > { currentValueRef.current = currentValue ?? ""; setNeedsSaving((lastSavedContent.current ?? "").trimEnd() !== getPreprocessedData(currentValue ?? "").trimEnd()); setError(undefined); }} onClick={(e, pos) => { if (pos && pos.textNode && pos.textNode.data) { const clickIndex = getClickIndex(pos); let parsedAttrs: Attribute[]; try { parsedAttrs = attribute_parser.lexAndParse(getPreprocessedData(currentValueRef.current), true); } catch (e) { // the input is incorrect because the user messed up with it and now needs to fix it manually return null; } let matchedAttr: Attribute | null = null; for (const attr of parsedAttrs) { if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) { matchedAttr = attr; break; } } setTimeout(() => { if (matchedAttr) { attributeDetailWidget.showAttributeDetail({ allAttributes: parsedAttrs, attribute: matchedAttr, isOwned: true, x: e.pageX, y: e.pageY }); setState("showAttributeDetail"); } else { setState("showHelpTooltip"); } }, 100); } else { setState("showHelpTooltip"); } }} disableNewlines disableSpellcheck /> { needsSaving && } { error && (
{typeof error === "object" && "message" in error && typeof error.message === "string" && error.message}
)}
{attributeDetailWidgetEl} ) } function getPreprocessedData(currentValue: string) { const str = currentValue .replace(/]+href="(#[A-Za-z0-9_/]*)"[^>]*>[^<]*<\/a>/g, "$1") .replace(/ /g, " "); // otherwise .text() below outputs non-breaking space in unicode return $("
").html(str).text(); } function getClickIndex(pos: ModelPosition) { let clickIndex = pos.offset - (pos.textNode?.startOffset ?? 0); let curNode: ModelNode | Text | ModelElement | null = pos.textNode; while (curNode?.previousSibling) { curNode = curNode.previousSibling; if ((curNode as ModelElement).name === "reference") { clickIndex += (curNode.getAttribute("href") as string).length + 1; } else if ("data" in curNode) { clickIndex += (curNode.data as string).length; } } return clickIndex; }