Fix Notepad synchronization across pages and external updates
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-12 12:36:31 +02:00
parent 898fd5286f
commit b0e340efc7
7 changed files with 148 additions and 77 deletions

View file

@ -14,14 +14,13 @@ import {
TEXT_COLORS,
formatUpdatedAt,
getPlainText,
getSelectionTextOffset,
isHtmlEmpty,
queryBlockFormat,
queryCommandState,
restoreSelectionTextOffset
queryCommandState
} from "./notepadUtils.js";
const EMPTY_NOTEPAD_DATA = { text: "" };
const EMPTY_FORMATS = { bold: false, italic: false, underline: false, strikeThrough: false, heading: false, bulletList: false, numberList: false };
function readTemporaryStrokes(sessionKey) {
try {
@ -41,9 +40,9 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
const activePage = normalizedData.pages.find((page) => page.id === normalizedData.activePageId) || normalizedData.pages[0];
const editorRef = useRef(null);
const surfaceRef = useRef(null);
const selectionOffsetRef = useRef(null);
const localSaveUpdatedAtRef = useRef("");
const pageKeyRef = useRef(`${toolboxId}:${moduleId}:${activePage.id}`);
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
const [editorFocused, setEditorFocused] = useState(false);
const [previousSource, setPreviousSource] = useState({ pageKey, page: activePage, focused: false });
const temporaryStrokesDirtyRef = useRef(false);
const sessionStorageFailureRef = useRef("");
const [html, setHtml] = useState(activePage.html);
@ -58,52 +57,40 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
const [editingPageId, setEditingPageId] = useState("");
const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title])));
const [activeFormats, setActiveFormats] = useState({
bold: false,
italic: false,
underline: false,
strikeThrough: false,
heading: false,
bulletList: false,
numberList: false
});
const [activeFormats, setActiveFormats] = useState(EMPTY_FORMATS);
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`;
const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes);
useEffect(() => {
useLayoutEffect(() => {
notepadDataRef.current = normalizedData;
}, [normalizedData]);
useEffect(() => {
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
const pageChanged = pageKeyRef.current !== pageKey;
if (pageChanged) {
pageKeyRef.current = pageKey;
localSaveUpdatedAtRef.current = "";
}
if (!pageChanged && document.activeElement === editorRef.current) return;
if (activePage.updatedAt && activePage.updatedAt === localSaveUpdatedAtRef.current) return;
const editor = editorRef.current;
if (document.activeElement === editor && getPlainText(editor) === activePage.text) {
const pageChanged = previousSource.pageKey !== pageKey;
const sourceChanged = ["html", "text", "updatedAt", "drawingMode"].some((field) => previousSource.page[field] !== activePage[field]);
if (pageChanged || sourceChanged || previousSource.focused !== editorFocused) {
setPreviousSource({ pageKey, page: activePage, focused: editorFocused });
// Une page différente remplace l'éditeur ; une mise à jour de la page courante attend le blur.
if (pageChanged || !editorFocused) {
setHtml(activePage.html);
setText(activePage.text);
setUpdatedAt(activePage.updatedAt);
setDrawingMode(activePage.drawingMode);
return;
if (pageChanged || activePage.html !== html || activePage.text !== text || activePage.drawingMode !== drawingMode) {
setDrawingActive(false);
setEraseActive(false);
}
}
setHtml(activePage.html);
setText(activePage.text);
setUpdatedAt(activePage.updatedAt);
setDrawingMode(activePage.drawingMode);
setDrawingActive(false);
setEraseActive(false);
}, [toolboxId, moduleId, activePage.id, activePage.html, activePage.text, activePage.updatedAt, activePage.drawingMode]);
if (pageChanged) {
setEditorFocused(false);
setActiveFormats(EMPTY_FORMATS);
}
}
useLayoutEffect(() => {
if (!editorRef.current || editorRef.current.innerHTML === html) return;
if (document.activeElement === editorRef.current) return;
editorRef.current.innerHTML = html;
restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current);
}, [html, text]);
}, [html, text, pageKey, editorFocused]);
useEffect(() => {
function updateActiveFormats() {
@ -167,7 +154,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
function saveContent(nextHtml, nextText) {
const nextUpdatedAt = new Date().toISOString();
localSaveUpdatedAtRef.current = nextUpdatedAt;
setHtml(nextHtml);
setText(nextText);
setUpdatedAt(nextUpdatedAt);
@ -214,7 +200,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
}
function handleEditorInput() {
selectionOffsetRef.current = getSelectionTextOffset(editorRef.current);
const nextHtml = editorRef.current?.innerHTML || "";
const nextText = getPlainText(editorRef.current);
saveContent(nextHtml, nextText);
@ -421,6 +406,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
<div
key={pageKey}
ref={editorRef}
className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`}
contentEditable={!drawingActive}
@ -428,6 +414,8 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
role="textbox"
aria-multiline="true"
data-placeholder={textContent.placeholder || "Bloc Note..."}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
onInput={handleEditorInput}
onPaste={handlePaste}
/>

View file

@ -6,43 +6,6 @@ export function getPlainText(element) {
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
}
export function getSelectionTextOffset(container) {
const selection = document.getSelection();
if (!container || !selection?.rangeCount || !container.contains(selection.anchorNode)) return null;
const range = selection.getRangeAt(0);
const prefix = range.cloneRange();
prefix.selectNodeContents(container);
prefix.setEnd(range.startContainer, range.startOffset);
return prefix.toString().length;
}
export function restoreSelectionTextOffset(container, offset) {
if (!container || offset == null) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = offset;
let node = walker.nextNode();
while (node) {
const length = node.textContent.length;
if (remaining <= length) {
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return;
}
remaining -= length;
node = walker.nextNode();
}
const range = document.createRange();
range.selectNodeContents(container);
range.collapse(false);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export function isHtmlEmpty(html, text) {
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim();
}