UNPKG

@wordpress/editor

Version:
445 lines (444 loc) 13.6 kB
// packages/editor/src/components/collab-sidebar/hooks.js import { speak } from "@wordpress/a11y"; import { __ } from "@wordpress/i18n"; import { useState, useEffect, useMemo, useSyncExternalStore } from "@wordpress/element"; import { useEntityRecords, store as coreStore } from "@wordpress/core-data"; import { useDispatch, useRegistry, useSelect } from "@wordpress/data"; import { store as blockEditorStore, privateApis as blockEditorPrivateApis } from "@wordpress/block-editor"; import { store as noticesStore } from "@wordpress/notices"; import { getScrollContainer } from "@wordpress/dom"; import { decodeEntities } from "@wordpress/html-entities"; import { store as interfaceStore } from "@wordpress/interface"; import { RichTextData, create } from "@wordpress/rich-text"; import { store as editorStore } from "../../store/index.mjs"; import { FLOATING_NOTES_SIDEBAR } from "./constants.mjs"; import { unlock } from "../../lock-unlock.mjs"; import { createBoardStore } from "./board-store.mjs"; import { NOTE_FORMAT_NAME } from "./format.mjs"; import { applyNoteFormat, calculateNotePositions, findNoteInBlock, getInlineMarkerStart, getNoteIdsFromMetadata, addNoteIdToMetadata, removeNoteFormat, removeNoteIdFromMetadata } from "./utils.mjs"; var { cleanEmptyObject } = unlock(blockEditorPrivateApis); function useNoteThreads(postId) { const queryArgs = { post: postId, type: "note", status: "all", per_page: -1 }; const { records: threads } = useEntityRecords( "root", "comment", queryArgs, { enabled: !!postId && typeof postId === "number" } ); const { getBlockAttributes } = useSelect(blockEditorStore); const { clientIds } = useSelect((select) => { const { getClientIdsWithDescendants } = select(blockEditorStore); return { clientIds: getClientIdsWithDescendants() }; }, []); const { notes, unresolvedNotes } = useMemo(() => { if (!threads || threads.length === 0) { return { notes: [], unresolvedNotes: [] }; } const blocksWithNotes = {}; const clientIdByNoteId = /* @__PURE__ */ new Map(); for (const clientId of clientIds) { const metadata = getBlockAttributes(clientId)?.metadata; const noteIds = getNoteIdsFromMetadata(metadata); if (noteIds.length > 0) { blocksWithNotes[clientId] = noteIds; for (const noteId of noteIds) { clientIdByNoteId.set(noteId, clientId); } } } const threadsById = /* @__PURE__ */ new Map(); const rootThreads = []; for (const item of threads) { const thread = { ...item, reply: [], blockClientId: item.parent === 0 ? clientIdByNoteId.get(item.id) ?? null : null }; threadsById.set(item.id, thread); if (item.parent === 0) { rootThreads.push(thread); } } for (const item of threads) { if (item.parent !== 0) { threadsById.get(item.parent)?.reply.unshift(threadsById.get(item.id)); } } if (rootThreads.length === 0) { return { notes: [], unresolvedNotes: [] }; } const unresolved = []; const resolved = []; for (const [clientId, noteIds] of Object.entries( blocksWithNotes )) { const attributes = getBlockAttributes(clientId); const orderedThreads = noteIds.map((noteId) => { const thread = threadsById.get(noteId); if (!thread) { return null; } return { thread, start: getInlineMarkerStart(thread, attributes) }; }).filter(Boolean).sort((a, b) => { if (a.start !== b.start) { return a.start - b.start; } return a.thread.id - b.thread.id; }); for (const { thread } of orderedThreads) { if (thread.status === "hold") { unresolved.push(thread); } else if (thread.status === "approved") { resolved.push(thread); } } } const orphans = rootThreads.filter( (thread) => !thread.blockClientId ); return { notes: [...unresolved, ...orphans, ...resolved], unresolvedNotes: unresolved }; }, [clientIds, threads, getBlockAttributes]); return { notes, unresolvedNotes }; } function readInlineSelection(getSelectionStart, getSelectionEnd) { const start = getSelectionStart(); const end = getSelectionEnd(); if (!start?.clientId || start.clientId !== end.clientId || !start.attributeKey || start.offset === void 0 || end.offset === void 0 || start.offset === end.offset) { return null; } const [startOffset, endOffset] = start.offset < end.offset ? [start.offset, end.offset] : [end.offset, start.offset]; return { clientId: start.clientId, attributeKey: start.attributeKey, start: startOffset, end: endOffset }; } function wrapInlineNote(value, id, start, end) { if (!(value instanceof RichTextData)) { return null; } const record = applyNoteFormat( create({ html: value.toHTMLString() }), { type: NOTE_FORMAT_NAME, attributes: { "data-id": String(id) } }, start, end ); return RichTextData.fromHTMLString( new RichTextData(record).toHTMLString() ); } function clearInlineNoteMarker(noteId, getClientIdsWithDescendants, getBlockAttributes, updateBlockAttributes) { for (const clientId of getClientIdsWithDescendants()) { const attributes = getBlockAttributes(clientId); const found = findNoteInBlock(attributes, noteId); if (!found) { continue; } const next = removeNoteFormat( attributes[found.attributeKey], noteId ); if (next) { updateBlockAttributes(clientId, { [found.attributeKey]: next }); } return; } } function useNoteActions() { const { createNotice } = useDispatch(noticesStore); const { saveEntityRecord, deleteEntityRecord } = useDispatch(coreStore); const { getCurrentPostId } = useSelect(editorStore); const { getBlockAttributes, getClientIdsWithDescendants, getSelectedBlockClientId, getSelectionStart, getSelectionEnd } = useSelect(blockEditorStore); const { updateBlockAttributes } = useDispatch(blockEditorStore); const onError = (error) => { const errorMessage = error.message && error.code !== "unknown_error" ? decodeEntities(error.message) : __("An error occurred while performing an update."); createNotice("error", errorMessage, { type: "snackbar", isDismissible: true }); }; const onCreate = async ({ content, parent }) => { try { const inlineSelection = !parent ? readInlineSelection(getSelectionStart, getSelectionEnd) : null; const clientId = !parent ? inlineSelection?.clientId || getSelectedBlockClientId() : null; const savedRecord = await saveEntityRecord( "root", "comment", { post: getCurrentPostId(), content, status: "hold", type: "note", parent: parent || 0 }, { throwOnError: true } ); if (!parent && savedRecord?.id && clientId) { const attributes = getBlockAttributes(clientId); const metadata = attributes?.metadata; const updatedMetadata = addNoteIdToMetadata( metadata, savedRecord.id ); const newAttributes = { metadata: cleanEmptyObject(updatedMetadata) }; if (inlineSelection) { const wrapped = wrapInlineNote( attributes?.[inlineSelection.attributeKey], savedRecord.id, inlineSelection.start, inlineSelection.end ); if (wrapped) { newAttributes[inlineSelection.attributeKey] = wrapped; } } updateBlockAttributes(clientId, newAttributes); } createNotice( "snackbar", parent ? __("Reply added.") : __("Note added."), { type: "snackbar", isDismissible: true } ); return savedRecord; } catch (error) { onError(error); } }; const onEdit = async ({ id, content, status }) => { try { if (status === "approved" || status === "hold") { await saveEntityRecord( "root", "comment", { id, status }, { throwOnError: true } ); const newNoteData = { post: getCurrentPostId(), content: content || "", // Empty content for resolve, content for reopen. type: "note", status, parent: id, meta: { _wp_note_status: status === "approved" ? "resolved" : "reopen" } }; const savedRecord2 = await saveEntityRecord( "root", "comment", newNoteData, { throwOnError: true } ); if (status === "approved") { clearInlineNoteMarker( id, getClientIdsWithDescendants, getBlockAttributes, updateBlockAttributes ); } speak( status === "approved" ? __("Note marked as resolved.") : __("Note reopened.") ); return savedRecord2; } const updateData = { id, content, status }; const savedRecord = await saveEntityRecord( "root", "comment", updateData, { throwOnError: true } ); createNotice("snackbar", __("Note updated."), { type: "snackbar", isDismissible: true }); return savedRecord; } catch (error) { onError(error); } }; const onDelete = async (note) => { try { const clientId = !note.parent ? note.blockClientId || getSelectedBlockClientId() : null; await deleteEntityRecord("root", "comment", note.id, void 0, { throwOnError: true }); if (clientId) { const attributes = getBlockAttributes(clientId); const newAttributes = { metadata: cleanEmptyObject( removeNoteIdFromMetadata( attributes?.metadata, note.id ) ) }; const found = findNoteInBlock(attributes, note.id); if (found) { const next = removeNoteFormat( attributes[found.attributeKey], note.id ); if (next) { newAttributes[found.attributeKey] = next; } } updateBlockAttributes(clientId, newAttributes); } createNotice("snackbar", __("Note deleted."), { type: "snackbar", isDismissible: true }); return true; } catch (error) { onError(error); } }; return { onCreate, onEdit, onDelete }; } function useEnableFloatingSidebar(enabled = false) { const registry = useRegistry(); useEffect(() => { if (!enabled) { return; } const { getActiveComplementaryArea } = registry.select(interfaceStore); const { disableComplementaryArea, enableComplementaryArea } = registry.dispatch(interfaceStore); const unsubscribe = registry.subscribe(() => { if (getActiveComplementaryArea("core") === null) { enableComplementaryArea("core", FLOATING_NOTES_SIDEBAR); } }); return () => { unsubscribe(); if (getActiveComplementaryArea("core") === FLOATING_NOTES_SIDEBAR) { disableComplementaryArea("core", FLOATING_NOTES_SIDEBAR); } }; }, [enabled, registry]); } function useFloatingBoard({ threads, selectedNoteId, isFloating, sidebarRef }) { const [notePositions, setNotePositions] = useState({}); const [store] = useState(createBoardStore); const heights = useSyncExternalStore(store.subscribe, store.getSnapshot); useEffect(() => { if (!isFloating || !sidebarRef?.current) { return; } const panel = sidebarRef.current; const blockEl = store.getFirstBlockElement(); const rootEl = blockEl?.closest(".is-root-container") ?? blockEl; const canvas = rootEl ? getScrollContainer(rootEl) : null; const applyScroll = () => { panel.style.setProperty( "--canvas-scroll", `${-(canvas?.scrollTop ?? 0)}px` ); }; let rafId; const schedule = () => { window.cancelAnimationFrame(rafId); rafId = window.requestAnimationFrame(() => { const result = calculateNotePositions({ threads, selectedNoteId, blockRects: store.getAnchorRects(), heights, scrollTop: canvas?.scrollTop ?? 0 }); setNotePositions(result.positions); applyScroll(); }); }; schedule(); const contentObserver = new window.ResizeObserver(schedule); if (rootEl) { contentObserver.observe(rootEl); } const view = canvas?.ownerDocument?.defaultView; const listenerOptions = { passive: true, capture: true }; view?.addEventListener("scroll", applyScroll, listenerOptions); return () => { window.cancelAnimationFrame(rafId); contentObserver.disconnect(); view?.removeEventListener("scroll", applyScroll, listenerOptions); }; }, [sidebarRef, heights, isFloating, selectedNoteId, store, threads]); return { notePositions, registerThread: store.registerThread, unregisterThread: store.unregisterThread }; } export { useEnableFloatingSidebar, useFloatingBoard, useNoteActions, useNoteThreads }; //# sourceMappingURL=hooks.mjs.map