Fix Notepad synchronization across pages and external updates
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
898fd5286f
commit
b0e340efc7
7 changed files with 148 additions and 77 deletions
|
|
@ -35,3 +35,4 @@
|
||||||
{"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}}
|
{"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
{"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}}
|
{"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
|
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
|
{"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
|
||||||
- Vérifier l'affichage dans le panneau latéral.
|
- Vérifier l'affichage dans le panneau latéral.
|
||||||
- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente.
|
- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente.
|
||||||
- Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile.
|
- Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile.
|
||||||
|
- Vérifier qu'un changement de page avec le focus actif remplace immédiatement le contenu de l'éditeur ; une mise à jour externe de la même page attend le blur sans déplacer la sélection pendant la saisie ni reprendre le focus ensuite.
|
||||||
- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé.
|
- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé.
|
||||||
- Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.
|
- Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.
|
||||||
- Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image.
|
- Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image.
|
||||||
|
|
|
||||||
45
e2e/fixtures/notepad-sync-probe.js
Normal file
45
e2e/fixtures/notepad-sync-probe.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Rôle : pilote les mises à jour locales et externes du Bloc Note réel sous StrictMode.
|
||||||
|
import { createElement, StrictMode, useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { NotepadModule } from "../../website/src/features/toolboxes/modules/NotepadModule.jsx";
|
||||||
|
import { normalizeNotepadData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
|
export function mountNotepadSyncProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const writes = [];
|
||||||
|
let setInputs;
|
||||||
|
let inputs;
|
||||||
|
function Probe() {
|
||||||
|
const [value, setValue] = useState({
|
||||||
|
moduleId: "notes",
|
||||||
|
data: { activePageId: "a", pages: [
|
||||||
|
{ id: "a", title: "First", html: "<p>Alpha</p>" },
|
||||||
|
{ id: "b", title: "Second", html: "<p>Bravo</p>", drawingMode: "permanent" }
|
||||||
|
] }
|
||||||
|
});
|
||||||
|
inputs = value;
|
||||||
|
setInputs = setValue;
|
||||||
|
const context = {
|
||||||
|
getModuleData: () => value.data,
|
||||||
|
normalizeNotepadData,
|
||||||
|
uid: () => "new-page",
|
||||||
|
setModuleData: (_toolbox, moduleId, data) => {
|
||||||
|
writes.push({ moduleId, data });
|
||||||
|
setValue((current) => ({ ...current, data }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return createElement(NotepadModule, { toolboxId: "probe", moduleId: value.moduleId, context });
|
||||||
|
}
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
|
||||||
|
return {
|
||||||
|
writes,
|
||||||
|
read: () => inputs,
|
||||||
|
update: (patch) => flushSync(() => setInputs((current) => ({ ...current, ...patch }))),
|
||||||
|
patchPage: (id, patch) => flushSync(() => setInputs((current) => ({
|
||||||
|
...current, data: { ...current.data, pages: current.data.pages.map((page) => page.id === id ? { ...page, ...patch } : page) }
|
||||||
|
}))),
|
||||||
|
select: (id) => flushSync(() => setInputs((current) => ({ ...current, data: { ...current.data, activePageId: id } }))),
|
||||||
|
unmount: () => flushSync(() => root.unmount())
|
||||||
|
};
|
||||||
|
}
|
||||||
74
e2e/notepad-sync.spec.js
Normal file
74
e2e/notepad-sync.spec.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Rôle : vérifie le contenu et la sélection du Bloc Note face aux changements de page et aux mises à jour externes.
|
||||||
|
/* global window, document, NodeFilter */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/notepad-sync-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
async function setup(page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountNotepadSyncProbe } = await import(url);
|
||||||
|
document.getElementById("app").style.display = "none";
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.id = "notepad-probe";
|
||||||
|
document.body.prepend(element);
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.id = "outside-editor";
|
||||||
|
button.textContent = "Outside";
|
||||||
|
document.body.append(button);
|
||||||
|
window.notepadProbe = mountNotepadSyncProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
return page.locator("#notepad-probe .notepad-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("focused page changes never save the old DOM into the new page or module", async ({ page }) => {
|
||||||
|
const editor = await setup(page);
|
||||||
|
await expect(editor).toHaveText("Alpha");
|
||||||
|
await editor.focus();
|
||||||
|
const content = await page.evaluate(() => {
|
||||||
|
window.notepadProbe.select("b");
|
||||||
|
return document.querySelector("#notepad-probe .notepad-editor").innerHTML;
|
||||||
|
});
|
||||||
|
expect(content).toBe("<p>Bravo</p>");
|
||||||
|
await editor.fill("Second edited");
|
||||||
|
const saved = await page.evaluate(() => window.notepadProbe.writes.at(-1));
|
||||||
|
expect(saved.data.pages.find((item) => item.id === "a").html).toBe("<p>Alpha</p>");
|
||||||
|
expect(saved.data.pages.find((item) => item.id === "b").text).toBe("Second edited");
|
||||||
|
await page.evaluate(() => window.notepadProbe.update({
|
||||||
|
moduleId: "other", data: { activePageId: "b", pages: [{ id: "b", title: "Other", html: "<p>Other module</p>" }] }
|
||||||
|
}));
|
||||||
|
await expect(editor).toHaveText("Other module");
|
||||||
|
await editor.fill("Other edited");
|
||||||
|
expect(await page.evaluate(() => window.notepadProbe.writes.at(-1).moduleId)).toBe("other");
|
||||||
|
await page.evaluate(() => window.notepadProbe.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("local echoes preserve selection and external content synchronizes after blur", async ({ page }) => {
|
||||||
|
const editor = await setup(page);
|
||||||
|
await editor.fill("Local text");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const node = document.createTreeWalker(document.querySelector("#notepad-probe .notepad-editor"), NodeFilter.SHOW_TEXT).nextNode();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, 2);
|
||||||
|
range.collapse(true);
|
||||||
|
const selection = document.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
window.notepadProbe.update({});
|
||||||
|
});
|
||||||
|
expect(await page.evaluate(() => document.getSelection().anchorOffset)).toBe(2);
|
||||||
|
await expect(editor).toHaveText("Local text");
|
||||||
|
const writeCount = await page.evaluate(() => window.notepadProbe.writes.length);
|
||||||
|
await page.evaluate(() => window.notepadProbe.patchPage("a", { html: "<p>External text</p>" }));
|
||||||
|
await expect(editor).toHaveText("Local text");
|
||||||
|
expect(await page.evaluate(() => document.getSelection().anchorOffset)).toBe(2);
|
||||||
|
await page.locator("#outside-editor").click();
|
||||||
|
await expect(editor).toHaveText("External text");
|
||||||
|
await expect(page.locator("#outside-editor")).toBeFocused();
|
||||||
|
expect(await page.evaluate(() => window.notepadProbe.writes.length)).toBe(writeCount);
|
||||||
|
// L'horodatage reste celui de la sauvegarde locale ; il ne suffit pas à reconnaître un écho.
|
||||||
|
await page.evaluate(() => window.notepadProbe.patchPage("a", { html: "<p>Another update</p>" }));
|
||||||
|
await expect(editor).toHaveText("Another update");
|
||||||
|
await page.evaluate(() => window.notepadProbe.unmount());
|
||||||
|
});
|
||||||
|
|
@ -331,7 +331,6 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(notepadToolbar, /FormattingColorDropdown/);
|
assert.match(notepadToolbar, /FormattingColorDropdown/);
|
||||||
assert.match(notepadToolbar, /ListTypeDropdown/);
|
assert.match(notepadToolbar, /ListTypeDropdown/);
|
||||||
assert.match(notepadUtils, /export function getPlainText/);
|
assert.match(notepadUtils, /export function getPlainText/);
|
||||||
assert.match(notepadUtils, /export function restoreSelectionTextOffset/);
|
|
||||||
assert.match(combosModule, /export function CombosModule/);
|
assert.match(combosModule, /export function CombosModule/);
|
||||||
assert.match(combosUtils, /keyboardMouse/);
|
assert.match(combosUtils, /keyboardMouse/);
|
||||||
assert.match(combosUtils, /Nintendo 64/);
|
assert.match(combosUtils, /Nintendo 64/);
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,13 @@ import {
|
||||||
TEXT_COLORS,
|
TEXT_COLORS,
|
||||||
formatUpdatedAt,
|
formatUpdatedAt,
|
||||||
getPlainText,
|
getPlainText,
|
||||||
getSelectionTextOffset,
|
|
||||||
isHtmlEmpty,
|
isHtmlEmpty,
|
||||||
queryBlockFormat,
|
queryBlockFormat,
|
||||||
queryCommandState,
|
queryCommandState
|
||||||
restoreSelectionTextOffset
|
|
||||||
} from "./notepadUtils.js";
|
} from "./notepadUtils.js";
|
||||||
|
|
||||||
const EMPTY_NOTEPAD_DATA = { text: "" };
|
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) {
|
function readTemporaryStrokes(sessionKey) {
|
||||||
try {
|
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 activePage = normalizedData.pages.find((page) => page.id === normalizedData.activePageId) || normalizedData.pages[0];
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const surfaceRef = useRef(null);
|
const surfaceRef = useRef(null);
|
||||||
const selectionOffsetRef = useRef(null);
|
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
|
||||||
const localSaveUpdatedAtRef = useRef("");
|
const [editorFocused, setEditorFocused] = useState(false);
|
||||||
const pageKeyRef = useRef(`${toolboxId}:${moduleId}:${activePage.id}`);
|
const [previousSource, setPreviousSource] = useState({ pageKey, page: activePage, focused: false });
|
||||||
const temporaryStrokesDirtyRef = useRef(false);
|
const temporaryStrokesDirtyRef = useRef(false);
|
||||||
const sessionStorageFailureRef = useRef("");
|
const sessionStorageFailureRef = useRef("");
|
||||||
const [html, setHtml] = useState(activePage.html);
|
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 [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
|
||||||
const [editingPageId, setEditingPageId] = useState("");
|
const [editingPageId, setEditingPageId] = useState("");
|
||||||
const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title])));
|
const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title])));
|
||||||
const [activeFormats, setActiveFormats] = useState({
|
const [activeFormats, setActiveFormats] = useState(EMPTY_FORMATS);
|
||||||
bold: false,
|
|
||||||
italic: false,
|
|
||||||
underline: false,
|
|
||||||
strikeThrough: false,
|
|
||||||
heading: false,
|
|
||||||
bulletList: false,
|
|
||||||
numberList: false
|
|
||||||
});
|
|
||||||
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`;
|
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`;
|
||||||
const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes);
|
const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
notepadDataRef.current = normalizedData;
|
notepadDataRef.current = normalizedData;
|
||||||
}, [normalizedData]);
|
}, [normalizedData]);
|
||||||
|
|
||||||
useEffect(() => {
|
const pageChanged = previousSource.pageKey !== pageKey;
|
||||||
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
|
const sourceChanged = ["html", "text", "updatedAt", "drawingMode"].some((field) => previousSource.page[field] !== activePage[field]);
|
||||||
const pageChanged = pageKeyRef.current !== pageKey;
|
if (pageChanged || sourceChanged || previousSource.focused !== editorFocused) {
|
||||||
if (pageChanged) {
|
setPreviousSource({ pageKey, page: activePage, focused: editorFocused });
|
||||||
pageKeyRef.current = pageKey;
|
// Une page différente remplace l'éditeur ; une mise à jour de la page courante attend le blur.
|
||||||
localSaveUpdatedAtRef.current = "";
|
if (pageChanged || !editorFocused) {
|
||||||
}
|
setHtml(activePage.html);
|
||||||
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) {
|
|
||||||
setText(activePage.text);
|
setText(activePage.text);
|
||||||
setUpdatedAt(activePage.updatedAt);
|
setUpdatedAt(activePage.updatedAt);
|
||||||
setDrawingMode(activePage.drawingMode);
|
setDrawingMode(activePage.drawingMode);
|
||||||
return;
|
if (pageChanged || activePage.html !== html || activePage.text !== text || activePage.drawingMode !== drawingMode) {
|
||||||
|
setDrawingActive(false);
|
||||||
|
setEraseActive(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setHtml(activePage.html);
|
if (pageChanged) {
|
||||||
setText(activePage.text);
|
setEditorFocused(false);
|
||||||
setUpdatedAt(activePage.updatedAt);
|
setActiveFormats(EMPTY_FORMATS);
|
||||||
setDrawingMode(activePage.drawingMode);
|
}
|
||||||
setDrawingActive(false);
|
}
|
||||||
setEraseActive(false);
|
|
||||||
}, [toolboxId, moduleId, activePage.id, activePage.html, activePage.text, activePage.updatedAt, activePage.drawingMode]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!editorRef.current || editorRef.current.innerHTML === html) return;
|
if (!editorRef.current || editorRef.current.innerHTML === html) return;
|
||||||
if (document.activeElement === editorRef.current) return;
|
if (document.activeElement === editorRef.current) return;
|
||||||
editorRef.current.innerHTML = html;
|
editorRef.current.innerHTML = html;
|
||||||
restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current);
|
}, [html, text, pageKey, editorFocused]);
|
||||||
}, [html, text]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function updateActiveFormats() {
|
function updateActiveFormats() {
|
||||||
|
|
@ -167,7 +154,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
|
||||||
|
|
||||||
function saveContent(nextHtml, nextText) {
|
function saveContent(nextHtml, nextText) {
|
||||||
const nextUpdatedAt = new Date().toISOString();
|
const nextUpdatedAt = new Date().toISOString();
|
||||||
localSaveUpdatedAtRef.current = nextUpdatedAt;
|
|
||||||
setHtml(nextHtml);
|
setHtml(nextHtml);
|
||||||
setText(nextText);
|
setText(nextText);
|
||||||
setUpdatedAt(nextUpdatedAt);
|
setUpdatedAt(nextUpdatedAt);
|
||||||
|
|
@ -214,7 +200,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEditorInput() {
|
function handleEditorInput() {
|
||||||
selectionOffsetRef.current = getSelectionTextOffset(editorRef.current);
|
|
||||||
const nextHtml = editorRef.current?.innerHTML || "";
|
const nextHtml = editorRef.current?.innerHTML || "";
|
||||||
const nextText = getPlainText(editorRef.current);
|
const nextText = getPlainText(editorRef.current);
|
||||||
saveContent(nextHtml, nextText);
|
saveContent(nextHtml, nextText);
|
||||||
|
|
@ -421,6 +406,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
|
||||||
|
|
||||||
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
|
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
|
||||||
<div
|
<div
|
||||||
|
key={pageKey}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`}
|
className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`}
|
||||||
contentEditable={!drawingActive}
|
contentEditable={!drawingActive}
|
||||||
|
|
@ -428,6 +414,8 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
data-placeholder={textContent.placeholder || "Bloc Note..."}
|
data-placeholder={textContent.placeholder || "Bloc Note..."}
|
||||||
|
onFocus={() => setEditorFocused(true)}
|
||||||
|
onBlur={() => setEditorFocused(false)}
|
||||||
onInput={handleEditorInput}
|
onInput={handleEditorInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,43 +6,6 @@ export function getPlainText(element) {
|
||||||
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
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) {
|
export function isHtmlEmpty(html, text) {
|
||||||
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue