improve notepad features and add drawing canvas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
e73d754082
commit
de1b8a5638
20 changed files with 1732 additions and 141 deletions
|
|
@ -179,8 +179,49 @@ Alertes timer :
|
||||||
- le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ;
|
- le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ;
|
||||||
- un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures.
|
- un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures.
|
||||||
|
|
||||||
|
### Bloc Notes Riche
|
||||||
|
|
||||||
|
Le bloc notes utilise une edition directe en `contentEditable`, sans mode preview separe.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- toolbar compacte en haut de l'outil ;
|
||||||
|
- boutons courts pour gras, italique, souligne, paragraphes, titres et listes ;
|
||||||
|
- couleurs texte et surlignage regroupees dans un dropdown unique avec icone arc-en-ciel et libelles de categories ;
|
||||||
|
- date de derniere modification affichee en pied d'outil, sans prendre le dessus sur le contenu ;
|
||||||
|
- switch de stockage des dessins en pied d'outil, avec icone sauvegarde et etat actif ;
|
||||||
|
- placeholder integre dans la surface d'edition quand la note est vide ;
|
||||||
|
- le HTML persiste doit rester nettoye via allowlist, jamais via HTML libre.
|
||||||
|
|
||||||
|
### Calque De Dessin
|
||||||
|
|
||||||
|
Les annotations dessinees utilisent un canvas transparent superpose au contenu annote.
|
||||||
|
|
||||||
|
Regles UI :
|
||||||
|
|
||||||
|
- le calque est inactif par defaut pour laisser le contenu editable ;
|
||||||
|
- en mode dessin, les interactions pointer sont capturees par le canvas ;
|
||||||
|
- les controles de dessin sont regroupes dans un panneau reutilisable vertical, colle a droite de la surface annotee ;
|
||||||
|
- seuls les controles directement lies au dessin sont affiches quand le mode dessin est actif ;
|
||||||
|
- les controles restent compacts : gommer, annuler, effacer, couleur et epaisseur via dropdowns ;
|
||||||
|
- le mode temporaire privilegie le griffonnage de session et utilise `sessionStorage` ;
|
||||||
|
- le mode permanent suit le stockage IndexedDB de l'outil ;
|
||||||
|
- pour les surfaces scrollables, le canvas suit le contenu complet plutot que le viewport visible.
|
||||||
|
- sur les annotations d'images, les traits utilisent des coordonnees en pourcentage et restent visibles dans l'aperçu reduit ; l'edition se fait uniquement en plein ecran et le switch de sauvegarde reste disponible dans la barre de dessin.
|
||||||
|
|
||||||
## 8. Tabs Et Switchs
|
## 8. Tabs Et Switchs
|
||||||
|
|
||||||
|
### Dropdowns Compacts
|
||||||
|
|
||||||
|
Les menus flottants compacts d'icones, couleurs, epaisseurs ou actions rapides doivent reutiliser `CompactDropdown`.
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
|
||||||
|
- conserver un trigger compact, idealement icone seule avec tooltip ;
|
||||||
|
- fermer au clic exterieur, a `Escape`, a la selection et quand le focus sort du menu ;
|
||||||
|
- garder le contenu interne libre pour supporter grilles d'icones, swatches, libelles de categories ou choix d'epaisseur ;
|
||||||
|
- reutiliser les classes visuelles existantes du contexte quand le menu appartient a un outil precis.
|
||||||
|
|
||||||
Tabs :
|
Tabs :
|
||||||
|
|
||||||
- utiliser le composant commun quand un outil doit alterner entre plusieurs modes ;
|
- utiliser le composant commun quand un outil doit alterner entre plusieurs modes ;
|
||||||
|
|
|
||||||
|
|
@ -79,13 +79,36 @@ Type : `notepad`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"text": "Notes rapides..."
|
"html": "<p>Notes <strong>rapides</strong>...</p>",
|
||||||
|
"text": "Notes rapides...",
|
||||||
|
"updatedAt": "2026-07-29T12:00:00.000Z",
|
||||||
|
"drawingMode": "permanent",
|
||||||
|
"drawings": {
|
||||||
|
"strokes": [
|
||||||
|
{
|
||||||
|
"id": "stroke1",
|
||||||
|
"color": "#f6c453",
|
||||||
|
"width": 4,
|
||||||
|
"points": [
|
||||||
|
{ "x": 12.5, "y": 48 },
|
||||||
|
{ "x": 24, "y": 52.5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Stockage compact :
|
Stockage compact :
|
||||||
|
|
||||||
- si `text` est vide, l'entrée de module peut être supprimée.
|
- l'ancien format `{ "text": "..." }` reste accepté et est converti en HTML riche au prochain enregistrement ;
|
||||||
|
- `html` est nettoyé avec une allowlist de balises et de couleurs ;
|
||||||
|
- `text` conserve une version texte pour les exports et les fallbacks ;
|
||||||
|
- `updatedAt` est omis si vide ou invalide ;
|
||||||
|
- `drawingMode` vaut `temporary` par défaut et est omis dans ce cas ;
|
||||||
|
- `drawings` est stocké uniquement si `drawingMode` vaut `permanent` et qu'au moins un trait valide existe ;
|
||||||
|
- les dessins temporaires du bloc-notes sont conservés dans `sessionStorage`, pas dans IndexedDB ;
|
||||||
|
- si le texte, la date et les dessins permanents sont vides, l'entrée de module peut être supprimée.
|
||||||
|
|
||||||
## Outil Checklist
|
## Outil Checklist
|
||||||
|
|
||||||
|
|
@ -178,7 +201,20 @@ Type : `imageAnnotation`
|
||||||
"y": 68,
|
"y": 68,
|
||||||
"label": "Entrée"
|
"label": "Entrée"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"drawings": {
|
||||||
|
"strokes": [
|
||||||
|
{
|
||||||
|
"id": "stroke1",
|
||||||
|
"color": "#f6c453",
|
||||||
|
"width": 4,
|
||||||
|
"points": [
|
||||||
|
{ "x": 18.5, "y": 32 },
|
||||||
|
{ "x": 24, "y": 40.5 }
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -188,6 +224,10 @@ Stockage compact :
|
||||||
- `x` et `y` sont des pourcentages entre `0` et `100`.
|
- `x` et `y` sont des pourcentages entre `0` et `100`.
|
||||||
- `label` est facultatif.
|
- `label` est facultatif.
|
||||||
- `label` est omis si vide.
|
- `label` est omis si vide.
|
||||||
|
- `drawings` est facultatif et omis si aucun trait valide n'existe.
|
||||||
|
- les points des dessins sont stockés en pourcentages entre `0` et `100` pour rester alignés en aperçu réduit et en plein écran.
|
||||||
|
- les dessins sont éditables uniquement depuis la modale plein écran ; l'aperçu réduit les affiche en lecture seule.
|
||||||
|
- le mode temporaire des dessins d'image utilise `sessionStorage` et ne modifie pas `drawings` dans IndexedDB tant que l'utilisateur ne repasse pas en stockage permanent.
|
||||||
|
|
||||||
## Outil Timer
|
## Outil Timer
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,31 @@ export function validateSiteContent(site) {
|
||||||
"toolboxes.storageHelp.title",
|
"toolboxes.storageHelp.title",
|
||||||
"toolboxes.storageHelp.text",
|
"toolboxes.storageHelp.text",
|
||||||
"toolboxes.modules.notepad.placeholder",
|
"toolboxes.modules.notepad.placeholder",
|
||||||
|
"toolboxes.modules.notepad.toolbarLabel",
|
||||||
|
"toolboxes.modules.notepad.textFormatLabel",
|
||||||
|
"toolboxes.modules.notepad.boldTitle",
|
||||||
|
"toolboxes.modules.notepad.italicTitle",
|
||||||
|
"toolboxes.modules.notepad.underlineTitle",
|
||||||
|
"toolboxes.modules.notepad.paragraphTitle",
|
||||||
|
"toolboxes.modules.notepad.headingTitle",
|
||||||
|
"toolboxes.modules.notepad.bulletListTitle",
|
||||||
|
"toolboxes.modules.notepad.numberListTitle",
|
||||||
|
"toolboxes.modules.notepad.colorGroupLabel",
|
||||||
|
"toolboxes.modules.notepad.textColorLabel",
|
||||||
|
"toolboxes.modules.notepad.highlightColorLabel",
|
||||||
|
"toolboxes.modules.notepad.drawingLabel",
|
||||||
|
"toolboxes.modules.notepad.drawTitle",
|
||||||
|
"toolboxes.modules.notepad.eraseTitle",
|
||||||
|
"toolboxes.modules.notepad.temporaryTitle",
|
||||||
|
"toolboxes.modules.notepad.permanentTitle",
|
||||||
|
"toolboxes.modules.notepad.undoDrawingTitle",
|
||||||
|
"toolboxes.modules.notepad.clearDrawingTitle",
|
||||||
|
"toolboxes.modules.notepad.drawingColorLabel",
|
||||||
|
"toolboxes.modules.notepad.drawingWidthLabel",
|
||||||
|
"toolboxes.modules.notepad.temporaryStatus",
|
||||||
|
"toolboxes.modules.notepad.permanentStatus",
|
||||||
|
"toolboxes.modules.notepad.updatedAtPrefix",
|
||||||
|
"toolboxes.modules.notepad.sessionStorageError",
|
||||||
"toolboxes.modules.checklist.itemPlaceholder",
|
"toolboxes.modules.checklist.itemPlaceholder",
|
||||||
"toolboxes.modules.checklist.sectionPlaceholder",
|
"toolboxes.modules.checklist.sectionPlaceholder",
|
||||||
"toolboxes.modules.checklist.quantityLabel",
|
"toolboxes.modules.checklist.quantityLabel",
|
||||||
|
|
@ -246,6 +271,16 @@ export function validateSiteContent(site) {
|
||||||
"toolboxes.modules.imageAnnotation.emptyMarkers",
|
"toolboxes.modules.imageAnnotation.emptyMarkers",
|
||||||
"toolboxes.modules.imageAnnotation.deleteImageTitle",
|
"toolboxes.modules.imageAnnotation.deleteImageTitle",
|
||||||
"toolboxes.modules.imageAnnotation.deleteMarkerTitle",
|
"toolboxes.modules.imageAnnotation.deleteMarkerTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.drawingLabel",
|
||||||
|
"toolboxes.modules.imageAnnotation.drawTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.eraseTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.undoDrawingTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.clearDrawingTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.drawingColorLabel",
|
||||||
|
"toolboxes.modules.imageAnnotation.drawingWidthLabel",
|
||||||
|
"toolboxes.modules.imageAnnotation.temporaryTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.permanentTitle",
|
||||||
|
"toolboxes.modules.imageAnnotation.sessionStorageError",
|
||||||
"toolboxes.emptyTitle",
|
"toolboxes.emptyTitle",
|
||||||
"toolboxes.emptyText"
|
"toolboxes.emptyText"
|
||||||
].forEach((path) => assertNonEmptyString(site, path));
|
].forEach((path) => assertNonEmptyString(site, path));
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(moduleRegistry, /module-content/);
|
assert.match(moduleRegistry, /module-content/);
|
||||||
assert.match(moduleRegistry, /module-scroll-button/);
|
assert.match(moduleRegistry, /module-scroll-button/);
|
||||||
assert.match(moduleRegistry, /Icon name="dropdown"/);
|
assert.match(moduleRegistry, /Icon name="dropdown"/);
|
||||||
assert.match(moduleRegistry, /aria-expanded=\{quickOpen\}/);
|
assert.match(moduleRegistry, /CompactDropdown/);
|
||||||
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
|
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
|
||||||
assert.match(notepadModule, /export function NotepadModule/);
|
assert.match(notepadModule, /export function NotepadModule/);
|
||||||
assert.match(calculatorModule, /export function CalculatorModule/);
|
assert.match(calculatorModule, /export function CalculatorModule/);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||||
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
|
||||||
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeNotepadData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
test("colon text import keeps urls intact after the first separator", () => {
|
test("colon text import keeps urls intact after the first separator", () => {
|
||||||
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
|
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
|
||||||
|
|
@ -28,6 +28,82 @@ test("time pattern recurrence uses configured frequency instead of next remainin
|
||||||
assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000);
|
assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("notepad data migrates text, sanitizes html and compacts drawings", () => {
|
||||||
|
const migrated = normalizeNotepadData({ text: "Boss\nPhase 2" });
|
||||||
|
assert.equal(migrated.text, "Boss\nPhase 2");
|
||||||
|
assert.match(migrated.html, /<p>Boss<\/p><p>Phase 2<\/p>/);
|
||||||
|
|
||||||
|
const normalized = normalizeNotepadData({
|
||||||
|
html: '<h3 onclick="bad()">Titre</h3><script>alert(1)</script><span style="color: rgb(246, 196, 83); position: fixed">Important</span><img src=x>',
|
||||||
|
updatedAt: "2026-07-29T12:00:00.000Z",
|
||||||
|
drawingMode: "permanent",
|
||||||
|
drawings: {
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
id: "stroke1",
|
||||||
|
color: "#f6c453",
|
||||||
|
width: 40,
|
||||||
|
points: [{ x: 1.123 }, { x: 4, y: 8 }, { x: 12.345, y: 16.789 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(normalized.html, /<h3>Titre<\/h3>/);
|
||||||
|
assert.match(normalized.html, /<span style="color: #f6c453">Important<\/span>/);
|
||||||
|
assert.doesNotMatch(normalized.html, /script|onclick|position|img/);
|
||||||
|
assert.equal(normalized.updatedAt, "2026-07-29T12:00:00.000Z");
|
||||||
|
assert.equal(normalized.drawings.strokes[0].width, 24);
|
||||||
|
assert.deepEqual(normalized.drawings.strokes[0].points, [{ x: 4, y: 8 }, { x: 12.35, y: 16.79 }]);
|
||||||
|
|
||||||
|
const compactTemporary = compactModuleDataForStorage("notepad", {
|
||||||
|
html: "<p>Note</p>",
|
||||||
|
text: "Note",
|
||||||
|
drawingMode: "temporary",
|
||||||
|
drawings: { strokes: normalized.drawings.strokes }
|
||||||
|
});
|
||||||
|
assert.equal(compactTemporary.drawings, undefined);
|
||||||
|
assert.equal(compactTemporary.drawingMode, undefined);
|
||||||
|
|
||||||
|
const compactPermanent = compactModuleDataForStorage("notepad", {
|
||||||
|
html: "<p>Note</p>",
|
||||||
|
text: "Note",
|
||||||
|
updatedAt: "not a date",
|
||||||
|
drawingMode: "permanent",
|
||||||
|
drawings: normalized.drawings
|
||||||
|
});
|
||||||
|
assert.equal(compactPermanent.updatedAt, undefined);
|
||||||
|
assert.equal(compactPermanent.drawingMode, "permanent");
|
||||||
|
assert.equal(compactPermanent.drawings.strokes.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("image annotation storage keeps drawing strokes as percent coordinates", () => {
|
||||||
|
const compact = compactModuleDataForStorage("imageAnnotation", {
|
||||||
|
image: "data:image/png;base64,aaa",
|
||||||
|
markers: [{ id: "marker1", x: 120, y: 50, label: "Boss" }],
|
||||||
|
drawings: {
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
id: "stroke1",
|
||||||
|
color: "#22d3ee",
|
||||||
|
width: 4,
|
||||||
|
points: [{ x: 10.123, y: 20.456 }, { x: 140, y: -4 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(compact.markers[0].x, 100);
|
||||||
|
assert.deepEqual(compact.drawings.strokes[0].points, [{ x: 10.12, y: 20.46 }, { x: 100, y: 0 }]);
|
||||||
|
|
||||||
|
const exported = createToolboxExportPayload(
|
||||||
|
{ id: "toolbox1", name: "Map", modules: [{ id: "module1", type: "imageAnnotation" }], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||||
|
{ "toolbox1:module1": compact }
|
||||||
|
);
|
||||||
|
assert.equal(exported.modules.m1.markers[0].id, "k1");
|
||||||
|
assert.equal(exported.modules.m1.drawings.strokes[0].id, "d1");
|
||||||
|
});
|
||||||
|
|
||||||
test("task planner data normalizes invalid settings and relations", () => {
|
test("task planner data normalizes invalid settings and relations", () => {
|
||||||
const normalized = normalizeTaskPlannerData({
|
const normalized = normalizeTaskPlannerData({
|
||||||
weeklyResetDay: 9,
|
weeklyResetDay: 9,
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,32 @@
|
||||||
},
|
},
|
||||||
"modules": {
|
"modules": {
|
||||||
"notepad": {
|
"notepad": {
|
||||||
"placeholder": "Notes rapides..."
|
"placeholder": "Notes rapides...",
|
||||||
|
"toolbarLabel": "Mise en page du bloc notes",
|
||||||
|
"textFormatLabel": "Style du texte",
|
||||||
|
"boldTitle": "Gras",
|
||||||
|
"italicTitle": "Italique",
|
||||||
|
"underlineTitle": "Souligné",
|
||||||
|
"paragraphTitle": "Paragraphe",
|
||||||
|
"headingTitle": "Titre",
|
||||||
|
"bulletListTitle": "Liste à puces",
|
||||||
|
"numberListTitle": "Liste numérotée",
|
||||||
|
"colorGroupLabel": "Couleurs",
|
||||||
|
"textColorLabel": "Couleur du texte",
|
||||||
|
"highlightColorLabel": "Surlignage",
|
||||||
|
"drawingLabel": "Dessin",
|
||||||
|
"drawTitle": "Dessiner",
|
||||||
|
"eraseTitle": "Gommer",
|
||||||
|
"temporaryTitle": "Annotations temporaires",
|
||||||
|
"permanentTitle": "Annotations permanentes",
|
||||||
|
"undoDrawingTitle": "Annuler le dernier trait",
|
||||||
|
"clearDrawingTitle": "Effacer les dessins",
|
||||||
|
"drawingColorLabel": "Couleur du dessin",
|
||||||
|
"drawingWidthLabel": "Épaisseur du trait",
|
||||||
|
"temporaryStatus": "Dessins temporaires",
|
||||||
|
"permanentStatus": "Dessins permanents",
|
||||||
|
"updatedAtPrefix": "Modifié le",
|
||||||
|
"sessionStorageError": "Les annotations temporaires n'ont pas pu être sauvegardées."
|
||||||
},
|
},
|
||||||
"checklist": {
|
"checklist": {
|
||||||
"itemPlaceholder": "Nouvel item",
|
"itemPlaceholder": "Nouvel item",
|
||||||
|
|
@ -381,7 +406,17 @@
|
||||||
"emptyImage": "Ajoutez une image pour commencer l'annotation.",
|
"emptyImage": "Ajoutez une image pour commencer l'annotation.",
|
||||||
"emptyMarkers": "Agrandissez l'image pour ajouter un marqueur.",
|
"emptyMarkers": "Agrandissez l'image pour ajouter un marqueur.",
|
||||||
"deleteImageTitle": "Supprimer l'image",
|
"deleteImageTitle": "Supprimer l'image",
|
||||||
"deleteMarkerTitle": "Supprimer"
|
"deleteMarkerTitle": "Supprimer",
|
||||||
|
"drawingLabel": "Dessin",
|
||||||
|
"drawTitle": "Dessiner",
|
||||||
|
"eraseTitle": "Gommer",
|
||||||
|
"undoDrawingTitle": "Annuler le dernier trait",
|
||||||
|
"clearDrawingTitle": "Effacer les dessins",
|
||||||
|
"drawingColorLabel": "Couleur du dessin",
|
||||||
|
"drawingWidthLabel": "Épaisseur du trait",
|
||||||
|
"temporaryTitle": "Annotations temporaires",
|
||||||
|
"permanentTitle": "Annotations permanentes",
|
||||||
|
"sessionStorageError": "Les annotations temporaires n'ont pas pu être sauvegardées."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emptyTitle": "Aucune toolbox",
|
"emptyTitle": "Aucune toolbox",
|
||||||
|
|
|
||||||
4
website/public/static/icons/back.svg
Normal file
4
website/public/static/icons/back.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 10L3.29289 10.7071L2.58579 10L3.29289 9.29289L4 10ZM21 18C21 18.5523 20.5523 19 20 19C19.4477 19 19 18.5523 19 18L21 18ZM8.29289 15.7071L3.29289 10.7071L4.70711 9.29289L9.70711 14.2929L8.29289 15.7071ZM3.29289 9.29289L8.29289 4.29289L9.70711 5.70711L4.70711 10.7071L3.29289 9.29289ZM4 9L14 9L14 11L4 11L4 9ZM21 16L21 18L19 18L19 16L21 16ZM14 9C17.866 9 21 12.134 21 16L19 16C19 13.2386 16.7614 11 14 11L14 9Z" fill="#33363F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 668 B |
65
website/src/components/CompactDropdown.jsx
Normal file
65
website/src/components/CompactDropdown.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Rôle : fournit un dropdown compact réutilisable pour menus d'icônes, couleurs et actions rapides.
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function CompactDropdown({
|
||||||
|
className = "",
|
||||||
|
menuClassName = "",
|
||||||
|
label,
|
||||||
|
openOnHover = false,
|
||||||
|
closeOnMouseLeave = false,
|
||||||
|
preventMouseDown = false,
|
||||||
|
renderTrigger,
|
||||||
|
children
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
|
||||||
|
function handlePointerDown(event) {
|
||||||
|
if (!rootRef.current?.contains(event.target)) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
setOpen((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} ${open ? "is-open" : ""}`.trim()}
|
||||||
|
ref={rootRef}
|
||||||
|
onMouseEnter={openOnHover ? () => setOpen(true) : undefined}
|
||||||
|
onMouseLeave={closeOnMouseLeave ? close : undefined}
|
||||||
|
onFocusCapture={openOnHover ? () => setOpen(true) : undefined}
|
||||||
|
onBlurCapture={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
||||||
|
}}
|
||||||
|
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{renderTrigger({ open, toggle, close })}
|
||||||
|
{open && (
|
||||||
|
<div className={`${menuClassName} is-open`.trim()} aria-label={label}>
|
||||||
|
{children({ close })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
// Rôle : affiche les images en modale, avec support des annotations.
|
// Rôle : affiche les images en modale, avec support des annotations.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
|
||||||
|
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
|
||||||
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||||
import { Icon } from "./Icon.jsx";
|
import { Icon } from "./Icon.jsx";
|
||||||
|
|
||||||
|
const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
||||||
|
const DRAWING_WIDTHS = [2, 4, 8, 12];
|
||||||
|
|
||||||
function clampPercent(value) {
|
function clampPercent(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) return 0;
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
|
@ -31,13 +36,24 @@ function openImageInNewTab(dataUrl) {
|
||||||
export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) {
|
export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) {
|
||||||
useEffect(() => lockBodyScroll(), []);
|
useEffect(() => lockBodyScroll(), []);
|
||||||
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers);
|
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers);
|
||||||
|
const canDraw = Boolean(canAnnotate && image.onChangeDrawings);
|
||||||
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []);
|
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []);
|
||||||
|
const [viewerDrawings, setViewerDrawings] = useState(() => image.drawings?.strokes ? image.drawings : { strokes: [] });
|
||||||
|
const [drawingMode, setDrawingMode] = useState(image.drawingMode === "temporary" ? "temporary" : "permanent");
|
||||||
|
const [drawingActive, setDrawingActive] = useState(false);
|
||||||
|
const [eraseActive, setEraseActive] = useState(false);
|
||||||
|
const [drawingColor, setDrawingColor] = useState("#f6c453");
|
||||||
|
const [drawingWidth, setDrawingWidth] = useState(4);
|
||||||
const viewerRef = useRef(null);
|
const viewerRef = useRef(null);
|
||||||
const mediaRef = useRef(null);
|
const mediaRef = useRef(null);
|
||||||
const hasMarkers = viewerMarkers.length > 0;
|
const hasMarkers = viewerMarkers.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []);
|
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []);
|
||||||
|
setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] });
|
||||||
|
setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent");
|
||||||
|
setDrawingActive(false);
|
||||||
|
setEraseActive(false);
|
||||||
}, [image]);
|
}, [image]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -74,8 +90,28 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
||||||
image.onChangeMarkers?.(nextMarkers);
|
image.onChangeMarkers?.(nextMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateViewerDrawings(nextStrokes) {
|
||||||
|
const nextDrawings = { strokes: nextStrokes };
|
||||||
|
setViewerDrawings(nextDrawings);
|
||||||
|
image.onChangeDrawings?.(nextDrawings, drawingMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDrawingMode() {
|
||||||
|
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
|
||||||
|
setDrawingMode(nextMode);
|
||||||
|
image.onChangeDrawingMode?.(nextMode, viewerDrawings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaSurfaceSize() {
|
||||||
|
return {
|
||||||
|
width: mediaRef.current?.clientWidth || 0,
|
||||||
|
height: mediaRef.current?.clientHeight || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function addViewerMarker(event) {
|
function addViewerMarker(event) {
|
||||||
if (!canAnnotate || !mediaRef.current) return;
|
if (!canAnnotate || !mediaRef.current) return;
|
||||||
|
if (drawingActive || event.target.closest(".drawing-controls-panel")) return;
|
||||||
const rect = mediaRef.current.getBoundingClientRect();
|
const rect = mediaRef.current.getBoundingClientRect();
|
||||||
const nextMarkers = [
|
const nextMarkers = [
|
||||||
...viewerMarkers,
|
...viewerMarkers,
|
||||||
|
|
@ -121,6 +157,36 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
|
||||||
aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
|
aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
|
||||||
>
|
>
|
||||||
<img src={image.dataUrl} alt={image.alt || "Image"} />
|
<img src={image.dataUrl} alt={image.alt || "Image"} />
|
||||||
|
<DrawingOverlay
|
||||||
|
active={drawingActive}
|
||||||
|
erase={eraseActive}
|
||||||
|
strokes={viewerDrawings.strokes}
|
||||||
|
color={drawingColor}
|
||||||
|
width={drawingWidth}
|
||||||
|
onChange={updateViewerDrawings}
|
||||||
|
getSurfaceSize={getMediaSurfaceSize}
|
||||||
|
createStrokeId={image.createStrokeId}
|
||||||
|
coordinateMode="percent"
|
||||||
|
/>
|
||||||
|
{canDraw && (
|
||||||
|
<DrawingControls
|
||||||
|
textContent={image}
|
||||||
|
active={drawingActive}
|
||||||
|
onToggleActive={() => setDrawingActive((value) => !value)}
|
||||||
|
eraseActive={eraseActive}
|
||||||
|
onToggleErase={() => setEraseActive((value) => !value)}
|
||||||
|
color={drawingColor}
|
||||||
|
colors={DRAWING_COLORS}
|
||||||
|
onColorChange={setDrawingColor}
|
||||||
|
width={drawingWidth}
|
||||||
|
widths={DRAWING_WIDTHS}
|
||||||
|
onWidthChange={setDrawingWidth}
|
||||||
|
onUndo={() => updateViewerDrawings(viewerDrawings.strokes.slice(0, -1))}
|
||||||
|
onClear={() => updateViewerDrawings([])}
|
||||||
|
storageMode={drawingMode}
|
||||||
|
onToggleStorageMode={toggleDrawingMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{viewerMarkers.map((marker, index) => (
|
{viewerMarkers.map((marker, index) => (
|
||||||
<span
|
<span
|
||||||
className="annotation-marker image-viewer-marker"
|
className="annotation-marker image-viewer-marker"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Rôle : affiche une toolbox dans la liste, avec actions rapides et icône personnalisable.
|
// Rôle : affiche une toolbox dans la liste, avec actions rapides et icône personnalisable.
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
|
import { CompactDropdown } from "../../components/CompactDropdown.jsx";
|
||||||
import { Icon } from "../../components/Icon.jsx";
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TOOLBOX_ICON,
|
DEFAULT_TOOLBOX_ICON,
|
||||||
|
|
@ -73,54 +74,37 @@ export function ToolboxCard({ toolbox, game, actions, draggingToolboxId, dropTar
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolboxIconPicker({ toolbox, onChange }) {
|
export function ToolboxIconPicker({ toolbox, onChange }) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const pickerRef = useRef(null);
|
|
||||||
const icon = normalizeToolboxIcon(toolbox.icon);
|
const icon = normalizeToolboxIcon(toolbox.icon);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return undefined;
|
|
||||||
|
|
||||||
function handlePointerDown(event) {
|
|
||||||
if (!pickerRef.current?.contains(event.target)) setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
|
||||||
if (event.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function selectIcon(nextIcon) {
|
|
||||||
onChange(nextIcon);
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="toolbox-icon-picker" ref={pickerRef}>
|
<CompactDropdown
|
||||||
|
className="toolbox-icon-picker"
|
||||||
|
menuClassName="toolbox-icon-picker-menu"
|
||||||
|
label="Icônes de toolbox"
|
||||||
|
renderTrigger={({ open, toggle }) => (
|
||||||
<button
|
<button
|
||||||
className="toolbox-icon-picker-button"
|
className="toolbox-icon-picker-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={toggle}
|
||||||
aria-label="Changer l'icône de la toolbox"
|
aria-label="Changer l'icône de la toolbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
title="Changer l'icône"
|
title="Changer l'icône"
|
||||||
>
|
>
|
||||||
<img src={icon} alt="" aria-hidden="true" />
|
<img src={icon} alt="" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
)}
|
||||||
<div className="toolbox-icon-picker-menu" aria-label="Icônes de toolbox">
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
{TOOLBOX_ICONS.map((path) => (
|
{TOOLBOX_ICONS.map((path) => (
|
||||||
<button
|
<button
|
||||||
key={path}
|
key={path}
|
||||||
className={path === icon ? "active" : ""}
|
className={path === icon ? "active" : ""}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectIcon(path)}
|
onClick={() => {
|
||||||
|
onChange(path);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
|
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
|
||||||
aria-pressed={path === icon}
|
aria-pressed={path === icon}
|
||||||
title={path.split("/").pop().replace(".png", "")}
|
title={path.split("/").pop().replace(".png", "")}
|
||||||
|
|
@ -128,9 +112,9 @@ export function ToolboxIconPicker({ toolbox, onChange }) {
|
||||||
<img src={path} alt="" aria-hidden="true" loading="lazy" />
|
<img src={path} alt="" aria-hidden="true" loading="lazy" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CompactDropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
normalizeCountersData,
|
normalizeCountersData,
|
||||||
normalizeImageAnnotationData,
|
normalizeImageAnnotationData,
|
||||||
normalizeLinksData,
|
normalizeLinksData,
|
||||||
|
normalizeNotepadData,
|
||||||
normalizeTaskPlannerData,
|
normalizeTaskPlannerData,
|
||||||
normalizeTimerData,
|
normalizeTimerData,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
|
|
@ -146,6 +147,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
||||||
normalizeCountersData,
|
normalizeCountersData,
|
||||||
normalizeCalculatorData,
|
normalizeCalculatorData,
|
||||||
normalizeImageAnnotationData,
|
normalizeImageAnnotationData,
|
||||||
|
normalizeNotepadData,
|
||||||
normalizeTimerData,
|
normalizeTimerData,
|
||||||
normalizeTaskPlannerData,
|
normalizeTaskPlannerData,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
|
|
|
||||||
180
website/src/features/toolboxes/modules/DrawingControls.jsx
Normal file
180
website/src/features/toolboxes/modules/DrawingControls.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
// Rôle : rend les contrôles compacts des calques de dessin réutilisables.
|
||||||
|
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||||
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
|
||||||
|
const DEFAULT_WIDTHS = [2, 4, 8, 12];
|
||||||
|
|
||||||
|
export function DrawingControls({ textContent = {}, active, onToggleActive, eraseActive, onToggleErase, color, colors, onColorChange, width, widths = DEFAULT_WIDTHS, onWidthChange, onUndo, onClear, storageMode, onToggleStorageMode }) {
|
||||||
|
return (
|
||||||
|
<div className={`drawing-controls-panel ${active ? "is-open" : ""}`} aria-label={textContent.drawingLabel || "Dessin"}>
|
||||||
|
<button className={`drawing-control-button ${active ? "active" : ""}`} type="button" aria-pressed={active} title={textContent.drawTitle || "Dessiner"} onClick={onToggleActive}>
|
||||||
|
<Icon name="edit" />
|
||||||
|
</button>
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`drawing-control-button ${eraseActive ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={eraseActive}
|
||||||
|
title={textContent.eraseTitle || "Gommer"}
|
||||||
|
onClick={onToggleErase}
|
||||||
|
>
|
||||||
|
<Icon name="rubber" />
|
||||||
|
</button>
|
||||||
|
<DrawingColorDropdown
|
||||||
|
label={textContent.drawingColorLabel || "Couleur du dessin"}
|
||||||
|
colors={colors}
|
||||||
|
value={color}
|
||||||
|
onSelect={onColorChange}
|
||||||
|
/>
|
||||||
|
<DrawingWidthDropdown
|
||||||
|
label={textContent.drawingWidthLabel || "Épaisseur du trait"}
|
||||||
|
widths={widths}
|
||||||
|
value={width}
|
||||||
|
onSelect={onWidthChange}
|
||||||
|
/>
|
||||||
|
<button className="drawing-control-button" type="button" title={textContent.undoDrawingTitle || "Annuler le dernier trait"} onClick={onUndo}>
|
||||||
|
<Icon name="back" />
|
||||||
|
</button>
|
||||||
|
<button className="drawing-control-button danger" type="button" title={textContent.clearDrawingTitle || "Effacer les dessins"} onClick={onClear}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
{onToggleStorageMode && (
|
||||||
|
<button
|
||||||
|
className={`module-scroll-button drawing-storage-switch ${storageMode === "permanent" ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={storageMode === "permanent"}
|
||||||
|
aria-label={storageMode === "permanent" ? textContent.permanentTitle || "Annotations permanentes" : textContent.temporaryTitle || "Annotations temporaires"}
|
||||||
|
title={storageMode === "permanent" ? textContent.permanentTitle || "Annotations permanentes" : textContent.temporaryTitle || "Annotations temporaires"}
|
||||||
|
onClick={onToggleStorageMode}
|
||||||
|
>
|
||||||
|
<Icon name="save" />
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormattingColorDropdown({ label, sections }) {
|
||||||
|
return (
|
||||||
|
<CompactDropdown
|
||||||
|
className="notepad-color-dropdown"
|
||||||
|
menuClassName="notepad-color-menu is-sectioned"
|
||||||
|
label={label}
|
||||||
|
closeOnMouseLeave
|
||||||
|
preventMouseDown
|
||||||
|
renderTrigger={({ toggle }) => (
|
||||||
|
<button className="notepad-color-toggle is-rainbow" type="button" title={label} aria-label={label} onClick={toggle} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div className="notepad-color-section" key={section.label}>
|
||||||
|
<span className="notepad-color-section-label">{section.label}</span>
|
||||||
|
<div className="notepad-color-section-swatches">
|
||||||
|
{section.colors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={`${section.label}-${color}`}
|
||||||
|
className={`notepad-swatch ${section.value === color ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
title={`${section.label} ${color}`}
|
||||||
|
aria-label={`${section.label} ${color}`}
|
||||||
|
aria-pressed={section.value === color}
|
||||||
|
style={{ "--swatch-color": color }}
|
||||||
|
onClick={() => {
|
||||||
|
section.onSelect(color);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CompactDropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingColorDropdown({ label, colors, value, onSelect }) {
|
||||||
|
return (
|
||||||
|
<CompactDropdown
|
||||||
|
className="notepad-color-dropdown"
|
||||||
|
menuClassName="notepad-color-menu"
|
||||||
|
label={label}
|
||||||
|
closeOnMouseLeave
|
||||||
|
preventMouseDown
|
||||||
|
renderTrigger={({ toggle }) => (
|
||||||
|
<button
|
||||||
|
className="notepad-color-toggle is-rainbow"
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
style={{ "--swatch-color": value }}
|
||||||
|
onClick={toggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
{colors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className={`notepad-swatch ${value === color ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
title={`${label} ${color}`}
|
||||||
|
aria-label={`${label} ${color}`}
|
||||||
|
aria-pressed={value === color}
|
||||||
|
style={{ "--swatch-color": color }}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(color);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CompactDropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingWidthDropdown({ label, widths, value, onSelect }) {
|
||||||
|
return (
|
||||||
|
<CompactDropdown
|
||||||
|
className="drawing-width-dropdown"
|
||||||
|
menuClassName="drawing-width-menu"
|
||||||
|
label={label}
|
||||||
|
closeOnMouseLeave
|
||||||
|
preventMouseDown
|
||||||
|
renderTrigger={({ toggle }) => (
|
||||||
|
<button className="drawing-width-toggle" type="button" title={label} aria-label={label} onClick={toggle}>
|
||||||
|
<span style={{ "--line-width": `${value}px` }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
{widths.map((lineWidth) => (
|
||||||
|
<button
|
||||||
|
key={lineWidth}
|
||||||
|
className={`drawing-width-button ${value === lineWidth ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === lineWidth}
|
||||||
|
title={`${label} ${lineWidth}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(lineWidth);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ "--line-width": `${lineWidth}px` }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CompactDropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
website/src/features/toolboxes/modules/DrawingOverlay.jsx
Normal file
142
website/src/features/toolboxes/modules/DrawingOverlay.jsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
// Rôle : fournit un calque canvas réutilisable pour les annotations dessinées.
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function getCanvasPoint(event, canvas, coordinateMode = "pixel") {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
if (coordinateMode === "percent") return { x: rect.width ? (x / rect.width) * 100 : 0, y: rect.height ? (y / rect.height) * 100 : 0 };
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointDistance(a, b) {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCanvasPoint(point, size, coordinateMode) {
|
||||||
|
if (coordinateMode === "percent") return { x: (point.x / 100) * size.width, y: (point.y / 100) * size.height };
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeHitsPoint(stroke, point, size, coordinateMode) {
|
||||||
|
const canvasPoint = toCanvasPoint(point, size, coordinateMode);
|
||||||
|
const threshold = Math.max(10, (Number(stroke.width) || 4) * 2.2);
|
||||||
|
return stroke.points.some((strokePoint) => pointDistance(toCanvasPoint(strokePoint, size, coordinateMode), canvasPoint) <= threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStroke(context, stroke, size, coordinateMode) {
|
||||||
|
if (!stroke.points.length) return;
|
||||||
|
context.strokeStyle = stroke.color;
|
||||||
|
context.lineWidth = stroke.width;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
stroke.points.forEach((point, index) => {
|
||||||
|
const canvasPoint = toCanvasPoint(point, size, coordinateMode);
|
||||||
|
if (index === 0) context.moveTo(canvasPoint.x, canvasPoint.y);
|
||||||
|
else context.lineTo(canvasPoint.x, canvasPoint.y);
|
||||||
|
});
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingOverlay({ active, erase, strokes, color, width, onChange, getSurfaceSize, createStrokeId, coordinateMode = "pixel", className = "" }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const drawingRef = useRef(null);
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function syncSize() {
|
||||||
|
const nextSize = getSurfaceSize?.() || { width: 0, height: 0 };
|
||||||
|
setSize({
|
||||||
|
width: Math.max(0, Math.ceil(nextSize.width || 0)),
|
||||||
|
height: Math.max(0, Math.ceil(nextSize.height || 0))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSize();
|
||||||
|
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncSize) : null;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (observer && canvas?.parentElement) observer.observe(canvas.parentElement);
|
||||||
|
window.addEventListener("resize", syncSize);
|
||||||
|
return () => {
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener("resize", syncSize);
|
||||||
|
};
|
||||||
|
}, [getSurfaceSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !size.width || !size.height) return;
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.max(1, Math.floor(size.width * ratio));
|
||||||
|
canvas.height = Math.max(1, Math.floor(size.height * ratio));
|
||||||
|
canvas.style.width = `${size.width}px`;
|
||||||
|
canvas.style.height = `${size.height}px`;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
|
context.clearRect(0, 0, size.width, size.height);
|
||||||
|
strokes.forEach((stroke) => drawStroke(context, stroke, size, coordinateMode));
|
||||||
|
}, [coordinateMode, size, strokes]);
|
||||||
|
|
||||||
|
function commitStroke(points) {
|
||||||
|
if (points.length < 2) return;
|
||||||
|
onChange([
|
||||||
|
...strokes,
|
||||||
|
{
|
||||||
|
id: createStrokeId?.() || `stroke_${Date.now().toString(36)}`,
|
||||||
|
color,
|
||||||
|
width,
|
||||||
|
points
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event) {
|
||||||
|
if (!active || event.button > 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const point = getCanvasPoint(event, canvas, coordinateMode);
|
||||||
|
if (erase) {
|
||||||
|
const nextStrokes = strokes.filter((stroke) => !strokeHitsPoint(stroke, point, size, coordinateMode));
|
||||||
|
if (nextStrokes.length !== strokes.length) onChange(nextStrokes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
|
drawingRef.current = { pointerId: event.pointerId, points: [point] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event) {
|
||||||
|
const drawing = drawingRef.current;
|
||||||
|
if (!drawing || drawing.pointerId !== event.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const point = getCanvasPoint(event, canvasRef.current, coordinateMode);
|
||||||
|
const lastPoint = drawing.points[drawing.points.length - 1];
|
||||||
|
if (pointDistance(toCanvasPoint(lastPoint, size, coordinateMode), toCanvasPoint(point, size, coordinateMode)) < 1.5) return;
|
||||||
|
drawing.points.push(point);
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
drawStroke(context, { color, width, points: [lastPoint, point] }, size, coordinateMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event) {
|
||||||
|
const drawing = drawingRef.current;
|
||||||
|
if (!drawing || drawing.pointerId !== event.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
canvasRef.current.releasePointerCapture?.(event.pointerId);
|
||||||
|
drawingRef.current = null;
|
||||||
|
commitStroke(drawing.points);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`drawing-overlay ${active ? "is-active" : ""} ${erase ? "is-erase" : ""} ${className}`.trim()}
|
||||||
|
aria-hidden="true"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||||
|
|
||||||
function markerLabel(index, marker, textContent) {
|
function markerLabel(index, marker, textContent) {
|
||||||
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
||||||
|
|
@ -9,9 +10,36 @@ function markerLabel(index, marker, textContent) {
|
||||||
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing }) {
|
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
|
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
|
||||||
const textContent = context.moduleText?.imageAnnotation || {};
|
const textContent = context.moduleText?.imageAnnotation || {};
|
||||||
|
const previewMediaRef = useRef(null);
|
||||||
|
const sessionKey = `sokkog:image-annotation-drawings:${toolboxId}:${moduleId}`;
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [drawingMode, setDrawingMode] = useState("permanent");
|
||||||
|
const [temporaryDrawings, setTemporaryDrawings] = useState({ strokes: [] });
|
||||||
const markers = data.markers;
|
const markers = data.markers;
|
||||||
|
const drawings = drawingMode === "permanent" ? data.drawings || { strokes: [] } : temporaryDrawings;
|
||||||
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
|
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
|
||||||
|
const getPreviewSurfaceSize = useCallback((element) => ({
|
||||||
|
width: element?.clientWidth || 0,
|
||||||
|
height: element?.clientHeight || 0
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "{\"strokes\":[]}");
|
||||||
|
setTemporaryDrawings(stored?.strokes ? stored : { strokes: [] });
|
||||||
|
} catch {
|
||||||
|
setTemporaryDrawings({ strokes: [] });
|
||||||
|
}
|
||||||
|
}, [sessionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawingMode !== "temporary") return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(sessionKey, JSON.stringify(temporaryDrawings));
|
||||||
|
} catch {
|
||||||
|
context.notify?.(textContent.sessionStorageError || "Les annotations temporaires n'ont pas pu être sauvegardées.");
|
||||||
|
}
|
||||||
|
}, [context, drawingMode, sessionKey, temporaryDrawings, textContent.sessionStorageError]);
|
||||||
|
|
||||||
function save(nextData) {
|
function save(nextData) {
|
||||||
context.setModuleData(toolboxId, moduleId, nextData);
|
context.setModuleData(toolboxId, moduleId, nextData);
|
||||||
|
|
@ -20,12 +48,14 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
||||||
async function setImageFromFiles(files) {
|
async function setImageFromFiles(files) {
|
||||||
const file = [...files].find((item) => item?.type?.startsWith("image/"));
|
const file = [...files].find((item) => item?.type?.startsWith("image/"));
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
save({ image: await context.compressImageFile(file), markers: [] });
|
save({ image: await context.compressImageFile(file), markers: [], drawings: { strokes: [] } });
|
||||||
|
setTemporaryDrawings({ strokes: [] });
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeImage() {
|
function removeImage() {
|
||||||
save({ image: "", markers: [] });
|
save({ image: "", markers: [], drawings: { strokes: [] } });
|
||||||
|
setTemporaryDrawings({ strokes: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -79,7 +109,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
||||||
{data.image ? (
|
{data.image ? (
|
||||||
<div className="annotation-workspace">
|
<div className="annotation-workspace">
|
||||||
<div className="annotation-stage annotation-stage-preview" aria-label={textContent.stageAriaLabel || "Image annotée"}>
|
<div className="annotation-stage annotation-stage-preview" aria-label={textContent.stageAriaLabel || "Image annotée"}>
|
||||||
<div className="annotation-media">
|
<div className="annotation-media" ref={previewMediaRef}>
|
||||||
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
|
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
|
||||||
<div className="annotation-preview-actions">
|
<div className="annotation-preview-actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -88,14 +118,35 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
||||||
onClick={() => context.setImage({
|
onClick={() => context.setImage({
|
||||||
dataUrl: data.image,
|
dataUrl: data.image,
|
||||||
markers,
|
markers,
|
||||||
|
drawings,
|
||||||
alt: textContent.imageAlt || "Image annotée",
|
alt: textContent.imageAlt || "Image annotée",
|
||||||
canAnnotate: true,
|
canAnnotate: true,
|
||||||
createMarkerId: () => context.uid("marker"),
|
createMarkerId: () => context.uid("marker"),
|
||||||
|
createStrokeId: () => context.uid("stroke"),
|
||||||
|
drawingMode,
|
||||||
markerPrefix: textContent.markerPrefix || "Marqueur",
|
markerPrefix: textContent.markerPrefix || "Marqueur",
|
||||||
addMarkerAriaLabel: textContent.addMarkerAriaLabel || "Ajouter un marqueur sur l'image",
|
addMarkerAriaLabel: textContent.addMarkerAriaLabel || "Ajouter un marqueur sur l'image",
|
||||||
markerPlaceholder: textContent.markerPlaceholder || "Libellé du marqueur",
|
markerPlaceholder: textContent.markerPlaceholder || "Libellé du marqueur",
|
||||||
deleteMarkerTitle: textContent.deleteMarkerTitle || "Supprimer",
|
deleteMarkerTitle: textContent.deleteMarkerTitle || "Supprimer",
|
||||||
onChangeMarkers: (nextMarkers) => save({ ...data, markers: nextMarkers })
|
drawingLabel: textContent.drawingLabel || "Dessin",
|
||||||
|
drawTitle: textContent.drawTitle || "Dessiner",
|
||||||
|
eraseTitle: textContent.eraseTitle || "Gommer",
|
||||||
|
undoDrawingTitle: textContent.undoDrawingTitle || "Annuler le dernier trait",
|
||||||
|
clearDrawingTitle: textContent.clearDrawingTitle || "Effacer les dessins",
|
||||||
|
drawingColorLabel: textContent.drawingColorLabel || "Couleur du dessin",
|
||||||
|
drawingWidthLabel: textContent.drawingWidthLabel || "Épaisseur du trait",
|
||||||
|
temporaryTitle: textContent.temporaryTitle || "Annotations temporaires",
|
||||||
|
permanentTitle: textContent.permanentTitle || "Annotations permanentes",
|
||||||
|
onChangeMarkers: (nextMarkers) => save({ ...data, markers: nextMarkers }),
|
||||||
|
onChangeDrawings: (nextDrawings, nextMode = drawingMode) => {
|
||||||
|
if (nextMode === "permanent") save({ ...data, drawings: nextDrawings });
|
||||||
|
else setTemporaryDrawings(nextDrawings);
|
||||||
|
},
|
||||||
|
onChangeDrawingMode: (nextMode, currentDrawings) => {
|
||||||
|
setDrawingMode(nextMode);
|
||||||
|
if (nextMode === "permanent") save({ ...data, drawings: currentDrawings });
|
||||||
|
else setTemporaryDrawings(currentDrawings);
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
aria-label={textContent.previewAriaLabel || "Agrandir l'image annotée"}
|
aria-label={textContent.previewAriaLabel || "Agrandir l'image annotée"}
|
||||||
title={textContent.previewTitle || "Agrandir"}
|
title={textContent.previewTitle || "Agrandir"}
|
||||||
|
|
@ -118,6 +169,16 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
<DrawingOverlay
|
||||||
|
active={false}
|
||||||
|
erase={false}
|
||||||
|
strokes={drawings.strokes}
|
||||||
|
color="#f6c453"
|
||||||
|
width={4}
|
||||||
|
onChange={() => {}}
|
||||||
|
coordinateMode="percent"
|
||||||
|
getSurfaceSize={() => getPreviewSurfaceSize(previewMediaRef.current)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,328 @@
|
||||||
// Rôle : fournit l'outil bloc-notes libre.
|
// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
|
||||||
|
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||||
|
|
||||||
export function NotepadModule({ toolboxId, moduleId, context }) {
|
const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
||||||
const data = context.getModuleData(toolboxId, moduleId, { text: "" });
|
const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
|
||||||
const [text, setText] = useState(data.text || "");
|
const DRAWING_WIDTHS = [2, 4, 8, 12];
|
||||||
const textContent = context.moduleText?.notepad || {};
|
|
||||||
|
|
||||||
|
function getPlainText(element) {
|
||||||
|
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHtmlEmpty(html, text) {
|
||||||
|
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdatedAt(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryCommandState(command) {
|
||||||
|
try {
|
||||||
|
return document.queryCommandState(command);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryBlockFormat() {
|
||||||
|
try {
|
||||||
|
return String(document.queryCommandValue("formatBlock") || "").replace(/[<>]/g, "").toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({ active, title, children, onClick }) {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<button
|
||||||
className="notepad"
|
className={`notepad-toolbar-button ${active ? "active" : ""}`}
|
||||||
value={text}
|
type="button"
|
||||||
onChange={(event) => {
|
aria-pressed={active}
|
||||||
setText(event.target.value);
|
title={title}
|
||||||
context.setModuleData(toolboxId, moduleId, { text: event.target.value });
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
}}
|
onClick={onClick}
|
||||||
placeholder={textContent.placeholder || "Notes rapides..."}
|
>
|
||||||
/>
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotepadModule({ toolboxId, moduleId, context }) {
|
||||||
|
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" });
|
||||||
|
const normalizedData = useMemo(() => context.normalizeNotepadData(storedData), [context, storedData]);
|
||||||
|
const textContent = context.moduleText?.notepad || {};
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
const surfaceRef = useRef(null);
|
||||||
|
const [html, setHtml] = useState(normalizedData.html);
|
||||||
|
const [text, setText] = useState(normalizedData.text);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState(normalizedData.updatedAt);
|
||||||
|
const [drawingMode, setDrawingMode] = useState(normalizedData.drawingMode);
|
||||||
|
const [drawingActive, setDrawingActive] = useState(false);
|
||||||
|
const [eraseActive, setEraseActive] = useState(false);
|
||||||
|
const [drawingColor, setDrawingColor] = useState("#f6c453");
|
||||||
|
const [drawingWidth, setDrawingWidth] = useState(4);
|
||||||
|
const [textColor, setTextColor] = useState(TEXT_COLORS[0]);
|
||||||
|
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
|
||||||
|
const [activeFormats, setActiveFormats] = useState({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
paragraph: false,
|
||||||
|
heading: false,
|
||||||
|
bulletList: false,
|
||||||
|
numberList: false
|
||||||
|
});
|
||||||
|
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}`;
|
||||||
|
const [temporaryStrokes, setTemporaryStrokes] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHtml(normalizedData.html);
|
||||||
|
setText(normalizedData.text);
|
||||||
|
setUpdatedAt(normalizedData.updatedAt);
|
||||||
|
setDrawingMode(normalizedData.drawingMode);
|
||||||
|
setDrawingActive(false);
|
||||||
|
setEraseActive(false);
|
||||||
|
}, [toolboxId, moduleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || editorRef.current.innerHTML === html) return;
|
||||||
|
editorRef.current.innerHTML = html;
|
||||||
|
}, [html]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function updateActiveFormats() {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) {
|
||||||
|
setActiveFormats((current) => Object.values(current).some(Boolean)
|
||||||
|
? { bold: false, italic: false, underline: false, paragraph: false, heading: false, bulletList: false, numberList: false }
|
||||||
|
: current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const block = queryBlockFormat();
|
||||||
|
setActiveFormats({
|
||||||
|
bold: queryCommandState("bold"),
|
||||||
|
italic: queryCommandState("italic"),
|
||||||
|
underline: queryCommandState("underline"),
|
||||||
|
paragraph: block === "p" || block === "div",
|
||||||
|
heading: block === "h3" || block === "heading 3",
|
||||||
|
bulletList: queryCommandState("insertUnorderedList"),
|
||||||
|
numberList: queryCommandState("insertOrderedList")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("selectionchange", updateActiveFormats);
|
||||||
|
return () => document.removeEventListener("selectionchange", updateActiveFormats);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "[]");
|
||||||
|
setTemporaryStrokes(Array.isArray(stored) ? stored : []);
|
||||||
|
} catch {
|
||||||
|
setTemporaryStrokes([]);
|
||||||
|
}
|
||||||
|
}, [sessionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawingMode !== "temporary") return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(sessionKey, JSON.stringify(temporaryStrokes));
|
||||||
|
} catch {
|
||||||
|
context.notify?.(textContent.sessionStorageError || "Les annotations temporaires n'ont pas pu être sauvegardées.");
|
||||||
|
}
|
||||||
|
}, [context, drawingMode, sessionKey, temporaryStrokes, textContent.sessionStorageError]);
|
||||||
|
|
||||||
|
const activeStrokes = drawingMode === "permanent" ? normalizedData.drawings.strokes : temporaryStrokes;
|
||||||
|
const formattedUpdatedAt = formatUpdatedAt(updatedAt);
|
||||||
|
|
||||||
|
function saveContent(nextHtml, nextText) {
|
||||||
|
const nextUpdatedAt = new Date().toISOString();
|
||||||
|
setHtml(nextHtml);
|
||||||
|
setText(nextText);
|
||||||
|
setUpdatedAt(nextUpdatedAt);
|
||||||
|
context.setModuleData(toolboxId, moduleId, {
|
||||||
|
...normalizedData,
|
||||||
|
html: nextHtml,
|
||||||
|
text: nextText,
|
||||||
|
updatedAt: nextUpdatedAt,
|
||||||
|
drawingMode,
|
||||||
|
drawings: drawingMode === "permanent" ? { strokes: activeStrokes } : { strokes: [] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDrawingMode(nextMode, nextStrokes) {
|
||||||
|
setDrawingMode(nextMode);
|
||||||
|
if (nextMode === "temporary") setTemporaryStrokes(nextStrokes);
|
||||||
|
context.setModuleData(toolboxId, moduleId, {
|
||||||
|
...normalizedData,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
updatedAt,
|
||||||
|
drawingMode: nextMode,
|
||||||
|
drawings: nextMode === "permanent" ? { strokes: nextStrokes } : { strokes: [] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStrokes(nextStrokes) {
|
||||||
|
if (drawingMode === "permanent") {
|
||||||
|
context.setModuleData(toolboxId, moduleId, {
|
||||||
|
...normalizedData,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
updatedAt,
|
||||||
|
drawingMode,
|
||||||
|
drawings: { strokes: nextStrokes }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTemporaryStrokes(nextStrokes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditorInput() {
|
||||||
|
const nextHtml = editorRef.current?.innerHTML || "";
|
||||||
|
const nextText = getPlainText(editorRef.current);
|
||||||
|
saveContent(nextHtml, nextText);
|
||||||
|
requestAnimationFrame(() => document.dispatchEvent(new Event("selectionchange")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, value = null) {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
document.execCommand("styleWithCSS", false, true);
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
handleEditorInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBlock(tag) {
|
||||||
|
runCommand("formatBlock", tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextColor(color) {
|
||||||
|
setTextColor(color);
|
||||||
|
runCommand("foreColor", color);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHighlightColor(color) {
|
||||||
|
setHighlightColor(color);
|
||||||
|
runCommand("hiliteColor", color);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(event) {
|
||||||
|
const pastedText = event.clipboardData?.getData("text/plain");
|
||||||
|
if (!pastedText) return;
|
||||||
|
event.preventDefault();
|
||||||
|
document.execCommand("insertText", false, pastedText);
|
||||||
|
handleEditorInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSurfaceSize = useCallback(() => {
|
||||||
|
const element = surfaceRef.current;
|
||||||
|
return {
|
||||||
|
width: element?.clientWidth || 0,
|
||||||
|
height: Math.max(element?.scrollHeight || 0, element?.clientHeight || 0)
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleDrawingMode() {
|
||||||
|
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
|
||||||
|
saveDrawingMode(nextMode, activeStrokes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoStroke() {
|
||||||
|
saveStrokes(activeStrokes.slice(0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStrokes() {
|
||||||
|
saveStrokes([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notepad-module">
|
||||||
|
<div className="notepad-toolbar" aria-label={textContent.toolbarLabel || "Mise en page du bloc notes"}>
|
||||||
|
<div className="notepad-format-controls">
|
||||||
|
<div className="notepad-toolbar-group" role="group" aria-label={textContent.textFormatLabel || "Style du texte"}>
|
||||||
|
<ToolbarButton active={activeFormats.bold} title={textContent.boldTitle || "Gras"} onClick={() => runCommand("bold")}>B</ToolbarButton>
|
||||||
|
<ToolbarButton active={activeFormats.italic} title={textContent.italicTitle || "Italique"} onClick={() => runCommand("italic")}><em>I</em></ToolbarButton>
|
||||||
|
<ToolbarButton active={activeFormats.underline} title={textContent.underlineTitle || "Souligné"} onClick={() => runCommand("underline")}><u>U</u></ToolbarButton>
|
||||||
|
<ToolbarButton active={activeFormats.paragraph} title={textContent.paragraphTitle || "Paragraphe"} onClick={() => applyBlock("p")}>P</ToolbarButton>
|
||||||
|
<ToolbarButton active={activeFormats.heading} title={textContent.headingTitle || "Titre"} onClick={() => applyBlock("h3")}>H</ToolbarButton>
|
||||||
|
<ToolbarButton active={activeFormats.bulletList} title={textContent.bulletListTitle || "Liste à puces"} onClick={() => runCommand("insertUnorderedList")}>•</ToolbarButton>
|
||||||
|
<ToolbarButton active={activeFormats.numberList} title={textContent.numberListTitle || "Liste numérotée"} onClick={() => runCommand("insertOrderedList")}>1.</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
<div className="notepad-toolbar-group" role="group" aria-label={textContent.colorGroupLabel || "Couleurs"}>
|
||||||
|
<FormattingColorDropdown
|
||||||
|
label={textContent.colorGroupLabel || "Couleurs"}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
label: textContent.textColorLabel || "Couleur du texte",
|
||||||
|
colors: TEXT_COLORS,
|
||||||
|
value: textColor,
|
||||||
|
onSelect: applyTextColor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: textContent.highlightColorLabel || "Surlignage",
|
||||||
|
colors: HIGHLIGHT_COLORS,
|
||||||
|
value: highlightColor,
|
||||||
|
onSelect: applyHighlightColor
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`}
|
||||||
|
contentEditable={!drawingActive}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
data-placeholder={textContent.placeholder || "Notes rapides..."}
|
||||||
|
onInput={handleEditorInput}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
/>
|
||||||
|
<DrawingOverlay
|
||||||
|
active={drawingActive}
|
||||||
|
erase={eraseActive}
|
||||||
|
strokes={activeStrokes}
|
||||||
|
color={drawingColor}
|
||||||
|
width={drawingWidth}
|
||||||
|
onChange={saveStrokes}
|
||||||
|
getSurfaceSize={getSurfaceSize}
|
||||||
|
createStrokeId={() => context.uid("stroke")}
|
||||||
|
/>
|
||||||
|
<DrawingControls
|
||||||
|
textContent={textContent}
|
||||||
|
active={drawingActive}
|
||||||
|
onToggleActive={() => setDrawingActive((value) => !value)}
|
||||||
|
eraseActive={eraseActive}
|
||||||
|
onToggleErase={() => setEraseActive((value) => !value)}
|
||||||
|
color={drawingColor}
|
||||||
|
colors={TEXT_COLORS}
|
||||||
|
onColorChange={setDrawingColor}
|
||||||
|
width={drawingWidth}
|
||||||
|
widths={DRAWING_WIDTHS}
|
||||||
|
onWidthChange={setDrawingWidth}
|
||||||
|
onUndo={undoStroke}
|
||||||
|
onClear={clearStrokes}
|
||||||
|
storageMode={drawingMode}
|
||||||
|
onToggleStorageMode={toggleDrawingMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<footer className="notepad-footer">
|
||||||
|
{formattedUpdatedAt && <span>{`${textContent.updatedAtPrefix || "Modifié le"} ${formattedUpdatedAt}`}</span>}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
|
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
|
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
|
||||||
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
||||||
|
|
@ -75,8 +76,6 @@ function chooseMeasuredSplitIndex(modules, moduleHeights, fallbackSplitIndex) {
|
||||||
|
|
||||||
export function AddToolControls({ onAdd }) {
|
export function AddToolControls({ onAdd }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [quickOpen, setQuickOpen] = useState(false);
|
|
||||||
const controlsRef = useRef(null);
|
|
||||||
const modules = Object.entries(TOOLBOX_MODULES);
|
const modules = Object.entries(TOOLBOX_MODULES);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -84,73 +83,54 @@ export function AddToolControls({ onAdd }) {
|
||||||
return lockBodyScroll();
|
return lockBodyScroll();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!quickOpen) return undefined;
|
|
||||||
|
|
||||||
function handlePointerDown(event) {
|
|
||||||
if (!controlsRef.current?.contains(event.target)) setQuickOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
|
||||||
if (event.key === "Escape") setQuickOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [quickOpen]);
|
|
||||||
|
|
||||||
function addTool(type) {
|
function addTool(type) {
|
||||||
onAdd(type);
|
onAdd(type);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuickOpen(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="tool-add-controls"
|
className="tool-add-controls"
|
||||||
ref={controlsRef}
|
|
||||||
onMouseLeave={() => setQuickOpen(false)}
|
|
||||||
onFocusCapture={(event) => {
|
|
||||||
if (event.target.closest(".tool-add-quick-toggle")) setQuickOpen(true);
|
|
||||||
}}
|
|
||||||
onBlurCapture={(event) => {
|
|
||||||
if (!event.currentTarget.contains(event.relatedTarget)) setQuickOpen(false);
|
|
||||||
}}
|
|
||||||
aria-label="Ajouter un outil"
|
aria-label="Ajouter un outil"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="primary tool-add-modal-button"
|
className="primary tool-add-modal-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setOpen(true)}
|
||||||
setQuickOpen(false);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Ajouter un outil
|
Ajouter un outil
|
||||||
</button>
|
</button>
|
||||||
|
<CompactDropdown
|
||||||
|
className="tool-add-quick-dropdown"
|
||||||
|
menuClassName="tool-quick-add"
|
||||||
|
label="Ajout rapide"
|
||||||
|
openOnHover
|
||||||
|
closeOnMouseLeave
|
||||||
|
renderTrigger={({ open: quickOpen, toggle }) => (
|
||||||
<button
|
<button
|
||||||
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
|
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseEnter={() => setQuickOpen(true)}
|
onClick={toggle}
|
||||||
onClick={() => setQuickOpen((value) => !value)}
|
|
||||||
aria-label="Afficher l'ajout rapide"
|
aria-label="Afficher l'ajout rapide"
|
||||||
aria-expanded={quickOpen}
|
aria-expanded={quickOpen}
|
||||||
title="Ajout rapide"
|
title="Ajout rapide"
|
||||||
>
|
>
|
||||||
<Icon name="dropdown" />
|
<Icon name="dropdown" />
|
||||||
</button>
|
</button>
|
||||||
<div className={`tool-quick-add ${quickOpen ? "is-open" : ""}`} aria-label="Ajout rapide">
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
{modules.map(([type, module]) => (
|
{modules.map(([type, module]) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
className="tool-quick-add-button"
|
className="tool-quick-add-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAdd(type)}
|
onClick={() => {
|
||||||
|
onAdd(type);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
aria-label={`Ajouter ${module.label}`}
|
aria-label={`Ajouter ${module.label}`}
|
||||||
title={module.label}
|
title={module.label}
|
||||||
>
|
>
|
||||||
|
|
@ -159,7 +139,9 @@ export function AddToolControls({ onAdd }) {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</CompactDropdown>
|
||||||
</div>
|
</div>
|
||||||
{open && createPortal(
|
{open && createPortal(
|
||||||
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
|
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
|
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
|
||||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e" };
|
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
|
||||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
|
||||||
|
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
|
||||||
|
const NOTEPAD_RGB_COLOR_MAP = {
|
||||||
|
"rgb(245, 247, 255)": "#f5f7ff",
|
||||||
|
"rgb(180, 189, 211)": "#b4bdd3",
|
||||||
|
"rgb(246, 196, 83)": "#f6c453",
|
||||||
|
"rgb(34, 211, 238)": "#22d3ee",
|
||||||
|
"rgb(139, 92, 246)": "#8b5cf6",
|
||||||
|
"rgb(217, 70, 239)": "#d946ef",
|
||||||
|
"rgb(16, 20, 38)": "#101426",
|
||||||
|
"rgb(32, 39, 69)": "#202745"
|
||||||
|
};
|
||||||
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
|
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
|
||||||
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
||||||
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
|
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
|
||||||
|
|
@ -119,6 +131,136 @@ function parsePositiveInt(value, fallback = 1) {
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBasicEntities(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, "\"")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/gi, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStyleAttribute(value) {
|
||||||
|
const declarations = String(value || "")
|
||||||
|
.split(";")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const allowed = [];
|
||||||
|
declarations.forEach((declaration) => {
|
||||||
|
const [property, rawValue] = declaration.split(":").map((item) => item?.trim().toLowerCase());
|
||||||
|
if (!["color", "background-color"].includes(property)) return;
|
||||||
|
const color = NOTEPAD_RGB_COLOR_MAP[rawValue] || rawValue;
|
||||||
|
if (!NOTEPAD_ALLOWED_COLORS.has(color)) return;
|
||||||
|
allowed.push(`${property}: ${color}`);
|
||||||
|
});
|
||||||
|
return allowed.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNotepadHtml(value) {
|
||||||
|
const source = String(value || "")
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "");
|
||||||
|
let html = "";
|
||||||
|
let cursor = 0;
|
||||||
|
source.replace(/<\/?([a-z0-9]+)([^>]*)>/gi, (match, rawTag, rawAttributes, offset) => {
|
||||||
|
html += escapeHtml(source.slice(cursor, offset));
|
||||||
|
cursor = offset + match.length;
|
||||||
|
const tag = rawTag.toLowerCase();
|
||||||
|
if (!NOTEPAD_ALLOWED_TAGS.has(tag)) return "";
|
||||||
|
if (match.startsWith("</")) {
|
||||||
|
if (tag !== "br") html += `</${tag}>`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (tag === "br") {
|
||||||
|
html += "<br>";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const styleMatch = String(rawAttributes || "").match(/\sstyle=(?:"([^"]*)"|'([^']*)')/i);
|
||||||
|
const style = normalizeStyleAttribute(styleMatch?.[1] || styleMatch?.[2] || "");
|
||||||
|
html += style ? `<${tag} style="${style}">` : `<${tag}>`;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
html += escapeHtml(source.slice(cursor));
|
||||||
|
return html.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToNotepadHtml(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function notepadHtmlToText(value) {
|
||||||
|
const withBreaks = String(value || "")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/(p|div|h3|h4|li)>/gi, "\n")
|
||||||
|
.replace(/<li[^>]*>/gi, "- ");
|
||||||
|
return decodeBasicEntities(withBreaks.replace(/<[^>]*>/g, ""))
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trimEnd())
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIsoDate(value) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
const timestamp = Date.parse(text);
|
||||||
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDrawingPoint(point, options = {}) {
|
||||||
|
const x = Number(point?.x);
|
||||||
|
const y = Number(point?.y);
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||||
|
const max = Number(options.max);
|
||||||
|
return {
|
||||||
|
x: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(x * 100) / 100)),
|
||||||
|
y: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(y * 100) / 100))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDrawingStroke(stroke, options = {}) {
|
||||||
|
const points = (Array.isArray(stroke?.points) ? stroke.points : []).map((point) => normalizeDrawingPoint(point, options)).filter(Boolean);
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
const color = NOTEPAD_ALLOWED_COLORS.has(String(stroke?.color || "").toLowerCase()) ? String(stroke.color).toLowerCase() : "#f6c453";
|
||||||
|
const width = Math.min(24, Math.max(1, Number(stroke?.width) || 4));
|
||||||
|
return {
|
||||||
|
id: stroke?.id || uid("stroke"),
|
||||||
|
color,
|
||||||
|
width: Math.round(width * 10) / 10,
|
||||||
|
points
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDrawingData(data, options = {}) {
|
||||||
|
return {
|
||||||
|
strokes: (Array.isArray(data?.strokes) ? data.strokes : []).map((stroke) => normalizeDrawingStroke(stroke, options)).filter(Boolean)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNotepadData(data) {
|
||||||
|
const source = data && typeof data === "object" ? data : {};
|
||||||
|
const legacyText = String(source.text || "");
|
||||||
|
const html = sanitizeNotepadHtml(source.html ? source.html : textToNotepadHtml(legacyText));
|
||||||
|
const text = notepadHtmlToText(html) || legacyText.trim();
|
||||||
|
const updatedAt = normalizeIsoDate(source.updatedAt);
|
||||||
|
const drawingMode = source.drawingMode === "permanent" ? "permanent" : "temporary";
|
||||||
|
const drawings = normalizeDrawingData(source.drawings);
|
||||||
|
return { html, text, updatedAt, drawingMode, drawings };
|
||||||
|
}
|
||||||
|
|
||||||
export function clampQty(value, target) {
|
export function clampQty(value, target) {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
const safeValue = Number.isFinite(parsed) ? parsed : 0;
|
const safeValue = Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
|
@ -413,7 +555,7 @@ export function normalizeImageAnnotationData(data) {
|
||||||
y: clampPercent(marker?.y),
|
y: clampPercent(marker?.y),
|
||||||
label: String(marker?.label || "").trim()
|
label: String(marker?.label || "").trim()
|
||||||
}));
|
}));
|
||||||
return { image, markers };
|
return { image, markers, drawings: normalizeDrawingData(data?.drawings, { max: 100 }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactChecklistItemForStorage(item) {
|
function compactChecklistItemForStorage(item) {
|
||||||
|
|
@ -438,8 +580,14 @@ function compactChecklistSectionForStorage(section) {
|
||||||
|
|
||||||
export function compactModuleDataForStorage(type, value) {
|
export function compactModuleDataForStorage(type, value) {
|
||||||
if (type === "notepad") {
|
if (type === "notepad") {
|
||||||
const text = String(value?.text || "");
|
const normalized = normalizeNotepadData(value);
|
||||||
return text ? { text } : null;
|
const compact = {};
|
||||||
|
if (normalized.html && normalized.text) compact.html = normalized.html;
|
||||||
|
if (normalized.text) compact.text = normalized.text;
|
||||||
|
if (normalized.updatedAt) compact.updatedAt = normalized.updatedAt;
|
||||||
|
if (normalized.drawingMode === "permanent") compact.drawingMode = "permanent";
|
||||||
|
if (normalized.drawingMode === "permanent" && normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
|
||||||
|
return Object.keys(compact).length ? compact : null;
|
||||||
}
|
}
|
||||||
if (type === "checklist") {
|
if (type === "checklist") {
|
||||||
const normalized = normalizeChecklistData(value);
|
const normalized = normalizeChecklistData(value);
|
||||||
|
|
@ -465,7 +613,7 @@ export function compactModuleDataForStorage(type, value) {
|
||||||
if (type === "imageAnnotation") {
|
if (type === "imageAnnotation") {
|
||||||
const normalized = normalizeImageAnnotationData(value);
|
const normalized = normalizeImageAnnotationData(value);
|
||||||
if (!normalized.image) return null;
|
if (!normalized.image) return null;
|
||||||
return {
|
const compact = {
|
||||||
image: normalized.image,
|
image: normalized.image,
|
||||||
markers: normalized.markers.map((marker) => {
|
markers: normalized.markers.map((marker) => {
|
||||||
const compact = { id: marker.id, x: marker.x, y: marker.y };
|
const compact = { id: marker.id, x: marker.x, y: marker.y };
|
||||||
|
|
@ -473,6 +621,8 @@ export function compactModuleDataForStorage(type, value) {
|
||||||
return compact;
|
return compact;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
if (normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
|
||||||
|
return compact;
|
||||||
}
|
}
|
||||||
if (type === "links") {
|
if (type === "links") {
|
||||||
const links = normalizeLinksData(value).links.map((link) => {
|
const links = normalizeLinksData(value).links.map((link) => {
|
||||||
|
|
@ -604,10 +754,16 @@ function remapModuleDataForExport(type, data, nextId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "imageAnnotation") {
|
if (type === "imageAnnotation") {
|
||||||
return {
|
const remapped = {
|
||||||
...compact,
|
...compact,
|
||||||
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
|
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
|
||||||
};
|
};
|
||||||
|
if (compact.drawings?.strokes) {
|
||||||
|
remapped.drawings = {
|
||||||
|
strokes: compact.drawings.strokes.map((stroke) => ({ ...stroke, id: nextId("stroke") }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return remapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "links") {
|
if (type === "links") {
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,11 @@
|
||||||
-webkit-mask-image: url("/static/icons/refresh.svg");
|
-webkit-mask-image: url("/static/icons/refresh.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-icon-back {
|
||||||
|
mask-image: url("/static/icons/back.svg");
|
||||||
|
-webkit-mask-image: url("/static/icons/back.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.ui-icon-trash {
|
.ui-icon-trash {
|
||||||
mask-image: url("/static/icons/trashcan.svg");
|
mask-image: url("/static/icons/trashcan.svg");
|
||||||
-webkit-mask-image: url("/static/icons/trashcan.svg");
|
-webkit-mask-image: url("/static/icons/trashcan.svg");
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-viewer-marker {
|
.image-viewer-marker {
|
||||||
|
z-index: 3;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
|
|
@ -155,7 +156,7 @@
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: max-content;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding-right: 4px;
|
padding: 4px 4px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer[aria-hidden="true"] {
|
.drawer[aria-hidden="true"] {
|
||||||
|
|
|
||||||
|
|
@ -774,17 +774,423 @@
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notepad {
|
.notepad-module {
|
||||||
display: block;
|
display: grid;
|
||||||
width: calc(100% - 32px);
|
gap: var(--space-3);
|
||||||
min-height: 240px;
|
|
||||||
margin: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
resize: vertical;
|
}
|
||||||
line-height: 1.6;
|
|
||||||
|
.notepad-toolbar,
|
||||||
|
.notepad-toolbar-group,
|
||||||
|
.notepad-format-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-toolbar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(5, 7, 17, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-format-controls {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-toolbar-group {
|
||||||
|
padding-right: 8px;
|
||||||
|
border-right: 1px solid rgba(150, 165, 205, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-toolbar-group:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-toolbar-button,
|
||||||
|
.drawing-control-button,
|
||||||
|
.notepad-swatch,
|
||||||
|
.notepad-color-toggle,
|
||||||
|
.drawing-width-toggle,
|
||||||
|
.drawing-width-button {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(150, 165, 205, 0.18);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(16, 20, 38, 0.74);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-toolbar-button .ui-icon,
|
||||||
|
.drawing-control-button .ui-icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-toolbar-button:hover,
|
||||||
|
.drawing-control-button:hover,
|
||||||
|
.notepad-swatch:hover,
|
||||||
|
.notepad-color-toggle:hover,
|
||||||
|
.drawing-width-toggle:hover,
|
||||||
|
.drawing-width-button:hover,
|
||||||
|
.notepad-toolbar-button.active,
|
||||||
|
.drawing-control-button.active,
|
||||||
|
.notepad-swatch.active,
|
||||||
|
.drawing-width-button.active {
|
||||||
|
border-color: rgba(246, 196, 83, 0.48);
|
||||||
|
background: rgba(246, 196, 83, 0.1);
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-toggle::before,
|
||||||
|
.notepad-swatch::before {
|
||||||
|
content: "";
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid rgba(245, 247, 255, 0.22);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--swatch-color);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(5, 7, 17, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-toggle.is-rainbow::before {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-color: rgba(245, 247, 255, 0.28);
|
||||||
|
background: conic-gradient(
|
||||||
|
from 20deg,
|
||||||
|
#f6c453,
|
||||||
|
#22d3ee,
|
||||||
|
#8b5cf6,
|
||||||
|
#d946ef,
|
||||||
|
#f5f7ff,
|
||||||
|
#f6c453
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 3px rgba(5, 7, 17, 0.34),
|
||||||
|
0 0 10px rgba(139, 92, 246, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 34px);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(10, 13, 28, 0.99);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -4px);
|
||||||
|
transition:
|
||||||
|
opacity 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-menu.is-sectioned {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-section + .notepad-color-section {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(150, 165, 205, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-section-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-section-swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 34px);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-dropdown:hover .notepad-color-menu,
|
||||||
|
.notepad-color-dropdown:focus-within .notepad-color-menu,
|
||||||
|
.notepad-color-dropdown.is-open .notepad-color-menu {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-menu::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-color-menu::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
border-right: 7px solid transparent;
|
||||||
|
border-bottom: 7px solid rgba(10, 13, 28, 0.99);
|
||||||
|
border-left: 7px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-width-toggle span,
|
||||||
|
.drawing-width-button span {
|
||||||
|
width: 20px;
|
||||||
|
height: var(--line-width);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-width-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-width-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: auto;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 34px);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(10, 13, 28, 0.99);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-width-dropdown:hover .drawing-width-menu,
|
||||||
|
.drawing-width-dropdown:focus-within .drawing-width-menu,
|
||||||
|
.drawing-width-dropdown.is-open .drawing-width-menu {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-width-menu::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-width-menu::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
border-top: 7px solid rgba(10, 13, 28, 0.99);
|
||||||
|
border-right: 7px solid transparent;
|
||||||
|
border-left: 7px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-surface {
|
||||||
|
position: relative;
|
||||||
|
min-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
background: rgba(5, 7, 17, 0.56);
|
background: rgba(5, 7, 17, 0.56);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notepad-surface:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-surface.is-drawing {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 260px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
outline: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor[contenteditable="false"] {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor.is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
position: absolute;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor h3,
|
||||||
|
.notepad-editor h4,
|
||||||
|
.notepad-editor p,
|
||||||
|
.notepad-editor ul,
|
||||||
|
.notepad-editor ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor h3 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor h4 {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-editor ul,
|
||||||
|
.notepad-editor ol {
|
||||||
|
padding-left: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-overlay.is-active {
|
||||||
|
cursor: crosshair;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-overlay.is-erase {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 50%;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(10, 13, 28, 0.94);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel:not(.is-open) {
|
||||||
|
width: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .drawing-storage-switch {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: 16px 30px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 34px;
|
||||||
|
gap: 7px;
|
||||||
|
justify-items: initial;
|
||||||
|
padding: 0 7px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .drawing-storage-switch .ui-icon {
|
||||||
|
justify-self: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .drawing-storage-switch i {
|
||||||
|
width: 30px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .notepad-color-menu {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: auto;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .notepad-color-dropdown:hover .notepad-color-menu,
|
||||||
|
.drawing-controls-panel .notepad-color-dropdown:focus-within .notepad-color-menu,
|
||||||
|
.drawing-controls-panel .notepad-color-dropdown.is-open .notepad-color-menu {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .notepad-color-menu::after {
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
bottom: auto;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-controls-panel .notepad-color-menu::before {
|
||||||
|
top: 100%;
|
||||||
|
right: auto;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
bottom: auto;
|
||||||
|
border-top: 7px solid rgba(10, 13, 28, 0.99);
|
||||||
|
border-right: 7px solid transparent;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 7px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notepad-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.inline-form,
|
.inline-form,
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -2864,6 +3270,7 @@ textarea:focus {
|
||||||
|
|
||||||
.annotation-marker {
|
.annotation-marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
left: clamp(0px, calc(var(--marker-x, 50%) - 15px), calc(100% - 30px));
|
left: clamp(0px, calc(var(--marker-x, 50%) - 15px), calc(100% - 30px));
|
||||||
top: clamp(0px, calc(var(--marker-y, 50%) - 15px), calc(100% - 30px));
|
top: clamp(0px, calc(var(--marker-y, 50%) - 15px), calc(100% - 30px));
|
||||||
|
|
@ -2909,6 +3316,7 @@ textarea:focus {
|
||||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-block: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.annotation-marker-list li > span {
|
.annotation-marker-list li > span {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue