Files
flowdeck/images/Guide-Technique-Complet-pour-Recr-er-l-diteur-Notion.html
T
bruno 06cdec52fa
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
Add comprehensive guide on recreating the Notion editor
2026-07-09 08:20:43 -04:00

1498 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guide Technique Complet pour Recréer l’Éditeur Notion</title>
<meta name="description" content="Difficulté : Expert · Temps estimé : 3 à 6 mois pour un clone complet 2 à 4 semaines pour un prototype avec les blocs de base
Quick Guide
1. Définir le modèl">
<meta property="og:title" content="Guide Technique Complet pour Recréer l’Éditeur Notion">
<meta property="og:description" content="Difficulté : Expert · Temps estimé : 3 à 6 mois pour un clone complet 2 à 4 semaines pour un prototype avec les blocs de base
Quick Guide
1. Définir le modèl">
<meta property="og:type" content="article">
<meta property="og:image" content="https://www.notion.com/front-static/meta/teams-and-agents.jpg">
<meta name="theme-color" content="#b8543a" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#131214" media="(prefers-color-scheme: dark)">
<link rel="icon" href="data:image/svg+xml,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'&gt;&lt;text y='75' font-size='75'&gt;O&lt;/text&gt;&lt;/svg&gt;">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--font-display: 'Charter', 'Iowan Old Style', Georgia, serif;
--font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--bg: #fbf9f4;
--bg-surface: #ffffff;
--bg-surface-alt: #f1ede4;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.16);
--text: #1a1817;
--text-dim: #5a5651;
--text-muted: #8a8580;
--accent: #b8543a;
--accent-light: #d97a5e;
--accent-bg: rgba(184,84,58,0.06);
--gold: #c9952e;
--gold-bg: rgba(201,149,46,0.09);
--aurora-a: rgba(184,84,58,0.10);
--aurora-b: rgba(201,149,46,0.08);
--aurora-c: rgba(64,98,128,0.07);
--radius: 12px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05);
--shadow-md: 0 4px 24px rgba(0,0,0,0.07);
--max-w: 760px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131214; --bg-surface: #1c1a1e; --bg-surface-alt: #25232a;
--border: rgba(255,255,255,0.07); --border-strong: rgba(255,255,255,0.16);
--text: #ece8e2; --text-dim: #a8a39c; --text-muted: #6f6b66;
--accent: #e88f73; --accent-light: #f4ad95; --accent-bg: rgba(232,143,115,0.09);
--gold: #e8c05a; --gold-bg: rgba(232,192,90,0.09);
--aurora-a: rgba(232,143,115,0.13);
--aurora-b: rgba(232,192,90,0.09);
--aurora-c: rgba(125,180,224,0.10);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4); --shadow-md: 0 4px 28px rgba(0,0,0,0.55);
}
}
html {
scroll-behavior: smooth;
/* Give smooth-scroll some breathing room so anchors land below the
fixed toolbar instead of being shoved straight under it. */
scroll-padding-top: 4rem;
}
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
line-height: 1.75;
font-size: 17px;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
min-height: 100vh;
}
/* ── Aurora background ─────────────────────────────────
Slowly-drifting layered blobs in the accent palette. Sits behind
the content, fixed to the viewport so scrolling doesn't reset the
composition. Subtle grain on top stops it reading as 'flat CSS'. */
body::before {
content: '';
position: fixed;
inset: -20vh -20vw;
z-index: -2;
background:
radial-gradient(40vw 50vh at 18% 22%, var(--aurora-a) 0%, transparent 60%),
radial-gradient(45vw 55vh at 82% 12%, var(--aurora-b) 0%, transparent 65%),
radial-gradient(55vw 60vh at 50% 88%, var(--aurora-c) 0%, transparent 70%);
filter: blur(20px);
animation: aurora-drift 28s ease-in-out infinite alternate;
pointer-events: none;
}
body::after {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
/* Subtle film-grain — SVG turbulence baked to a data-URL. */
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
opacity: 0.045;
mix-blend-mode: overlay;
}
@keyframes aurora-drift {
0% { transform: translate3d(0,0,0) scale(1); }
50% { transform: translate3d(2vw,-1vh,0) scale(1.04); }
100% { transform: translate3d(-1vw,1.5vh,0) scale(1.02); }
}
@media (prefers-reduced-motion: reduce) {
body::before { animation: none; }
}
/* ── Toolbar (top-right) ──────────────────────────── */
.toolbar {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
display: flex;
gap: 0.4rem;
opacity: 0.7;
transition: opacity 0.2s;
}
.toolbar:hover { opacity: 1; }
.toolbar button {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text);
font-family: inherit;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: background 0.15s;
position: relative;
}
.toolbar button:hover { background: var(--bg-surface-alt); }
.toolbar button svg { width: 14px; height: 14px; flex-shrink: 0; }
.toolbar .toast {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--text);
color: var(--bg);
padding: 4px 10px;
border-radius: 6px;
font-size: 0.72rem;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.toolbar .toast.show { opacity: 1; }
.dropdown { position: relative; }
.dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: var(--shadow-md);
overflow: hidden;
min-width: 140px;
}
.dropdown-menu.open { display: block; }
.dropdown-menu button {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
color: var(--text);
font-family: inherit;
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.dropdown-menu button:hover { background: var(--bg-surface-alt); }
/* ── Hero ──────────────────────────────────────────── */
.hero {
position: relative;
background: transparent;
color: var(--text);
padding: 5.5rem 2rem 2.5rem;
text-align: center;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 70% 60% at 50% 40%, color-mix(in srgb, var(--accent) 10%, transparent) 0%, transparent 70%);
pointer-events: none;
}
/* A hair-thin gradient hairline divider under the hero to anchor it
without putting it on a heavy boxed background. */
.hero::after {
content: '';
position: absolute;
left: 50%; bottom: 0;
width: min(60%, 320px);
height: 1px;
transform: translateX(-50%);
background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
}
.hero-label {
position: relative;
text-transform: uppercase;
letter-spacing: 0.28em;
font-size: 0.68rem;
font-weight: 600;
color: var(--accent);
opacity: 0.85;
margin-bottom: 1.4rem;
font-family: var(--font-body);
}
.hero h1 {
position: relative;
font-family: var(--font-display);
font-size: clamp(2rem, 4.5vw, 3rem);
font-weight: 600;
font-variation-settings: 'opsz' 120, 'SOFT' 50;
line-height: 1.15;
max-width: 720px;
margin: 0 auto;
letter-spacing: -0.02em;
color: var(--text);
}
/* ── Hero image ───────────────────────────────────── */
.hero-image {
max-width: var(--max-w);
margin: -2rem auto 0;
position: relative;
z-index: 1;
padding: 0 2rem;
}
.hero-image img {
width: 100%;
max-height: 360px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow-md);
display: block;
}
/* ── Section images ───────────────────────────────── */
.section-image {
margin: 1.5rem 0;
position: relative;
}
.section-image img {
width: 100%;
max-height: 300px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
display: block;
}
/* ── Per-image hide button ────────────────────────────
A small X that appears on hover (or always on touch) so users can
remove irrelevant OG images. Click POSTs the URL to the backend so
the next render skips it. */
.img-hide-btn {
position: absolute;
top: 10px; right: 10px;
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, transform 0.05s ease;
z-index: 2;
padding: 0;
}
.hero-image .img-hide-btn { top: 14px; right: 2.5rem; }
/* Reroll sits just to the left of the hide button. */
.img-reroll-btn {
position: absolute;
top: 10px; right: 46px;
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, transform 0.05s ease;
z-index: 2;
padding: 0;
}
.hero-image .img-reroll-btn { top: 14px; right: calc(2.5rem + 36px); }
.section-image:hover .img-hide-btn,
.section-image:hover .img-reroll-btn,
.hero-image:hover .img-hide-btn,
.hero-image:hover .img-reroll-btn { opacity: 1; }
.img-hide-btn:hover { background: var(--accent); }
.img-reroll-btn:hover { background: var(--accent); }
.img-hide-btn:active,
.img-reroll-btn:active { transform: scale(0.92); }
.img-reroll-btn.spinning svg { animation: img-reroll-spin 0.6s linear infinite; }
.img-reroll-btn:disabled { display: none; }
@keyframes img-reroll-spin { to { transform: rotate(360deg); } }
@media (hover: none) {
/* Touch devices have no hover — show the buttons at low opacity always. */
.img-hide-btn, .img-reroll-btn { opacity: 0.7; }
}
.section-image.fading,
.hero-image.fading {
opacity: 0;
transform: scale(0.96);
transition: opacity 0.25s ease, transform 0.25s ease;
}
/* ── Stats bar ─────────────────────────────────────── */
.stats-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.9rem 2rem;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
color: var(--text-dim);
}
.stat { display: flex; align-items: center; gap: 0.35rem; }
.stat-value { font-weight: 600; color: var(--text); }
/* ── Layout ────────────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: 200px 1fr;
max-width: calc(var(--max-w) + 260px);
margin: 0 auto;
}
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.toc-sidebar { display: none; }
}
/* ── TOC sidebar ───────────────────────────────────── */
.toc-sidebar {
position: sticky; top: 0; height: 100vh; overflow-y: auto;
padding: 3.2rem 0.8rem 2rem 1.4rem;
border-right: 1px solid var(--border);
font-size: 0.78rem;
}
.toc-sidebar nav { position: relative; }
.toc-sidebar nav a {
position: relative;
display: block;
color: var(--text-dim);
text-decoration: none;
padding: 0.42rem 0.7rem 0.42rem 0.85rem;
margin: 1px 0;
border-radius: 6px;
line-height: 1.4;
letter-spacing: -0.005em;
transition: color 0.18s ease, background 0.18s ease, padding-left 0.18s ease;
}
/* Sliding accent indicator on the left edge of each TOC link */
.toc-sidebar nav a::before {
content: '';
position: absolute;
left: 0; top: 50%;
width: 2px; height: 0;
background: var(--accent);
transform: translateY(-50%);
border-radius: 1px;
transition: height 0.18s ease, opacity 0.18s ease;
opacity: 0;
}
.toc-sidebar nav a:hover {
color: var(--text);
background: var(--accent-bg);
padding-left: 1rem;
}
.toc-sidebar nav a:hover::before {
height: 60%;
opacity: 1;
}
.toc-sidebar nav a.active {
color: var(--accent);
font-weight: 600;
background: var(--accent-bg);
}
.toc-sidebar nav a.active::before {
height: 80%;
opacity: 1;
}
.toc-sidebar nav a.depth-3 {
padding-left: 1.3rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.toc-sidebar nav a.depth-3:hover { padding-left: 1.45rem; }
/* ── Content ───────────────────────────────────────── */
.content { max-width: var(--max-w); padding: 3rem 2.5rem 4rem; }
/* Display headings — Fraunces optical-size driven so they get more
contrast and personality at the larger end. */
.content h2 {
font-family: var(--font-display);
font-size: clamp(1.55rem, 2.4vw, 1.85rem);
font-weight: 600;
font-variation-settings: 'opsz' 96, 'SOFT' 50;
margin: 3rem 0 1rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid transparent;
border-image: linear-gradient(90deg, var(--accent) 0%, transparent 65%) 1;
letter-spacing: -0.022em;
line-height: 1.2;
color: var(--text);
}
.content h2:first-child { margin-top: 0; }
.content h3 {
font-family: var(--font-display);
font-size: 1.22rem;
font-weight: 600;
font-variation-settings: 'opsz' 32;
margin: 2.2rem 0 0.6rem;
letter-spacing: -0.015em;
color: var(--text);
}
.content h4 {
font-family: var(--font-body);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
margin: 1.6rem 0 0.5rem;
}
.content p { margin-bottom: 1.1rem; hanging-punctuation: first last; }
/* Drop cap on the very first paragraph of the body — old-school editorial
touch that anchors the reader. */
.content > p:first-of-type::first-letter,
.content > h2:first-child + p::first-letter {
font-family: var(--font-display);
font-weight: 700;
font-variation-settings: 'opsz' 144;
font-size: 3.6em;
line-height: 0.85;
float: left;
margin: 0.15em 0.12em 0 -0.04em;
color: var(--accent);
}
.content a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent);
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
transition: text-decoration-color 0.15s, color 0.15s;
}
.content a:hover {
text-decoration-color: var(--accent);
color: var(--accent-light);
}
.content ul, .content ol { margin: 0 0 1.1rem 1.6rem; }
.content li { margin-bottom: 0.4rem; }
.content li::marker { color: var(--accent); }
.content li > ul, .content li > ol { margin-top: 0.4rem; margin-bottom: 0; }
.content blockquote {
position: relative;
border-left: 3px solid var(--gold);
background: var(--gold-bg);
padding: 1.1rem 1.4rem 1.1rem 2.6rem;
margin: 1.5rem 0;
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text);
font-family: var(--font-display);
font-style: italic;
font-size: 1.05rem;
line-height: 1.55;
}
.content blockquote::before {
content: '\201C';
position: absolute;
left: 0.5rem; top: 0.3rem;
font-family: var(--font-display);
font-size: 3rem;
font-style: normal;
color: var(--gold);
opacity: 0.5;
line-height: 1;
}
.content hr { border: none; height: 1px; background: linear-gradient(90deg, transparent, var(--border-strong), transparent); margin: 2rem 0; }
.content code { font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }
.content pre { background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }
.content pre code { background: none; padding: 0; }
.content table { width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }
.content th { text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }
.content td { padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.content tr:last-child td { border-bottom: none; }
.content tr:hover td { background: var(--accent-bg); }
/* ── Sources (collapsible list) ───────────────────── */
.sources-panel { margin-top: 3rem; border-top: 2px solid var(--border); padding-top: 1.5rem; }
.sources-panel details { margin: 0; }
.sources-panel summary {
display: flex; align-items: center; gap: 0.5rem;
cursor: pointer; font-size: 1rem; font-weight: 600;
color: var(--text); padding: 0.5rem 0; list-style: none;
user-select: none;
}
.sources-panel summary::-webkit-details-marker { display: none; }
.sources-panel summary::before {
content: '\25B6'; font-size: 0.65em; color: var(--text-muted);
transition: transform 0.2s;
}
.sources-panel details[open] summary::before { transform: rotate(90deg); }
.sources-list { padding: 0.5rem 0 0 0.25rem; }
.sources-list a {
display: flex; align-items: baseline; gap: 0.5rem;
padding: 0.35rem 0; font-size: 0.85rem;
color: var(--text); text-decoration: none;
transition: color 0.15s;
}
.sources-list a:hover { color: var(--accent); }
.sources-list .snum {
color: var(--text-muted); font-size: 0.75rem;
min-width: 1.5rem; text-align: right; flex-shrink: 0;
}
.sources-list .sdomain {
color: var(--text-muted); font-size: 0.75rem;
margin-left: auto; flex-shrink: 0;
}
/* ── Chat-about CTA ────────────────────────────────── */
.chat-cta {
margin: 3rem 0 1rem; padding: 1.5rem;
text-align: center;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg-surface);
}
.chat-cta-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
background: var(--accent); color: #fff;
border: none; border-radius: 8px; cursor: pointer;
font-family: inherit;
transition: filter 0.15s, transform 0.05s;
}
.chat-cta-btn:hover:not(:disabled) { filter: brightness(1.1); }
.chat-cta-btn:active:not(:disabled) { transform: translateY(1px); }
.chat-cta-btn:disabled { opacity: 0.6; cursor: progress; }
.chat-cta-hint {
margin-top: 8px; font-size: 0.8rem; color: var(--text-muted);
}
/* ── Footer ────────────────────────────────────────── */
.report-footer {
text-align: center; padding: 2rem; font-size: 0.75rem;
color: var(--text-muted); border-top: 1px solid var(--border); margin-top: 2rem;
}
/* ── Animations ────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
.content h2, .content h3, .content p, .content ul, .content ol,
.content blockquote, .content table, .content pre, .section-image {
animation: fadeUp 0.4s ease both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}
/* ── Print ─────────────────────────────────────────── */
@media print {
.toc-sidebar, .toolbar { display: none !important; }
.layout { grid-template-columns: 1fr; }
.hero { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
/* ── Category palettes ───────────────────────────────────
Override the accent + aurora vars per category so each report
type has a distinct visual identity. */
body.category-product {
--accent: #2a8a8c;
--accent-light: #4ab0b2;
--accent-bg: rgba(42,138,140,0.07);
--aurora-a: rgba(42,138,140,0.11);
--aurora-b: rgba(201,149,46,0.06);
--aurora-c: rgba(64,98,128,0.06);
}
body.category-comparison {
--accent: #7a4cb8;
--accent-light: #9d76d0;
--accent-bg: rgba(122,76,184,0.07);
--aurora-a: rgba(122,76,184,0.11);
--aurora-b: rgba(184,84,58,0.05);
--aurora-c: rgba(64,98,128,0.07);
}
body.category-howto {
--accent: #3d8a3d;
--accent-light: #62b162;
--accent-bg: rgba(61,138,61,0.07);
--aurora-a: rgba(61,138,61,0.11);
--aurora-b: rgba(201,149,46,0.07);
--aurora-c: rgba(42,138,140,0.05);
}
body.category-landscape {
--accent: #b88a2e;
--accent-light: #d4a955;
--accent-bg: rgba(184,138,46,0.08);
--aurora-a: rgba(184,138,46,0.13);
--aurora-b: rgba(184,84,58,0.06);
--aurora-c: rgba(122,76,184,0.05);
}
@media (prefers-color-scheme: dark) {
body.category-product {
--accent: #5cc8cb; --accent-light: #8fdde0;
--accent-bg: rgba(92,200,203,0.10);
--aurora-a: rgba(92,200,203,0.13);
--aurora-b: rgba(232,192,90,0.07);
--aurora-c: rgba(125,180,224,0.08);
}
body.category-comparison {
--accent: #b896e8; --accent-light: #d0b8f0;
--accent-bg: rgba(184,150,232,0.10);
--aurora-a: rgba(184,150,232,0.13);
--aurora-b: rgba(232,143,115,0.06);
--aurora-c: rgba(125,180,224,0.08);
}
body.category-howto {
--accent: #82c882; --accent-light: #a8dba8;
--accent-bg: rgba(130,200,130,0.09);
--aurora-a: rgba(130,200,130,0.12);
--aurora-b: rgba(232,192,90,0.07);
--aurora-c: rgba(92,200,203,0.07);
}
body.category-landscape {
--accent: #e6c069; --accent-light: #f0d390;
--accent-bg: rgba(230,192,105,0.10);
--aurora-a: rgba(230,192,105,0.15);
--aurora-b: rgba(232,143,115,0.07);
--aurora-c: rgba(184,150,232,0.06);
}
}
/* ── Per-category font pairings ───────────────────────
Body font shifts between serif (long-form categories) and sans
(practical/data categories) so each report reads as a different
publication, not just a re-tinted version of the same template. */
/* Long-form: literary serif for both display and body */
body:not([class*="category-"]),
body.category-landscape {
--font-body: 'Source Serif 4', 'Iowan Old Style', Georgia, serif;
}
/* Comparison: analytical serif display + clean sans body */
body.category-comparison {
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
}
/* How-to: friendly geometric sans, top to bottom */
body.category-howto {
--font-display: 'Manrope', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
}
/* Product: techy/engineery — IBM Plex Sans display + Inter body */
body.category-product {
--font-display: 'IBM Plex Sans', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
}
/* Source Serif sits visually larger than Inter at the same px — pull it
back one notch for the categories that use it as body so line length
and rhythm stay comparable across categories. */
body:not([class*="category-"]) body, /* no-op selector, kept for clarity */
body.category-landscape { font-size: 16.5px; }
/* Drop cap looks bad on geometric sans — kill it for those categories */
body.category-product .content > p:first-of-type::first-letter,
body.category-howto .content > p:first-of-type::first-letter,
body.category-comparison .content > p:first-of-type::first-letter,
body.category-product .content > h2:first-child + p::first-letter,
body.category-howto .content > h2:first-child + p::first-letter,
body.category-comparison .content > h2:first-child + p::first-letter {
font-size: 1em; float: none; margin: 0; color: inherit;
font-family: inherit; font-weight: inherit;
}
/* ── Per-category background effects ───────────────
Each category overrides body::before so the page reads as a
distinctly-textured surface. Aurora stays the default. */
/* Product → blueprint grid that slowly pans */
body.category-product::before {
background:
linear-gradient(to right, var(--aurora-a) 1px, transparent 1px),
linear-gradient(to bottom, var(--aurora-a) 1px, transparent 1px),
radial-gradient(70vw 60vh at 50% 50%, var(--aurora-a) 0%, transparent 75%);
background-size: 56px 56px, 56px 56px, 100% 100%;
filter: none;
animation: cat-grid-pan 60s linear infinite;
}
@keyframes cat-grid-pan {
to { background-position: 56px 56px, 56px 56px, 0 0; }
}
/* Comparison → dot grid + slow opacity pulse */
body.category-comparison::before {
background:
radial-gradient(circle, var(--aurora-a) 1.4px, transparent 1.8px),
radial-gradient(60vw 55vh at 25% 25%, var(--aurora-b) 0%, transparent 65%),
radial-gradient(60vw 55vh at 75% 75%, var(--aurora-c) 0%, transparent 65%);
background-size: 26px 26px, 100% 100%, 100% 100%;
filter: none;
animation: cat-dot-pulse 14s ease-in-out infinite alternate;
}
@keyframes cat-dot-pulse {
from { opacity: 0.65; }
to { opacity: 1; }
}
/* How-to → flat surface with a very subtle vignette. Drop the flow-lines
pattern — it competes visually with the step number rails on the
right-hand side of each H2. The reading should feel like an O'Reilly
procedure: clean, scannable, no decoration in the way. */
body.category-howto::before {
background:
radial-gradient(70vw 70vh at 50% 0%, var(--aurora-a) 0%, transparent 60%),
radial-gradient(50vw 50vh at 50% 100%, var(--aurora-b) 0%, transparent 65%);
filter: blur(40px);
animation: none;
}
/* Landscape → horizontal horizon bands that slowly shift sideways */
body.category-landscape::before {
background:
linear-gradient(
180deg,
transparent 0%,
var(--aurora-a) 22%,
transparent 35%,
var(--aurora-b) 55%,
transparent 68%,
var(--aurora-c) 85%,
transparent 100%
);
background-size: 100% 200%;
filter: blur(40px);
animation: cat-horizon-drift 36s ease-in-out infinite alternate;
}
@keyframes cat-horizon-drift {
0% { background-position: 0 0; }
100% { background-position: 0 100%; }
}
@media (prefers-reduced-motion: reduce) {
body.category-product::before,
body.category-comparison::before,
body.category-howto::before,
body.category-landscape::before {
animation: none;
}
}
/* ─────────────────────────────────────────────────────
PER-CATEGORY STRUCTURAL TREATMENTS
Each category gets distinctive structural CSS so the page
reads as a different publication — not just retinted.
───────────────────────────────────────────────────── */
/* ── HOWTO: O'Reilly-style numbered procedure ─────── */
body.category-howto .content { counter-reset: howto-step; }
body.category-howto .content h2 {
counter-increment: howto-step;
display: flex; align-items: center; gap: 14px;
border-bottom: none;
padding-left: 0;
margin-top: 3.5rem;
}
body.category-howto .content h2::before {
content: counter(howto-step);
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
width: 40px; height: 40px;
border-radius: 12px;
background: var(--accent);
color: #fff;
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0;
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
}
/* Step body gets a colored left rail so you can scan "this is step 1's stuff" */
body.category-howto .content h2 ~ p,
body.category-howto .content h2 ~ ul,
body.category-howto .content h2 ~ ol,
body.category-howto .content h2 ~ pre,
body.category-howto .content h2 ~ blockquote {
border-left: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
padding-left: 1rem;
margin-left: 4px;
}
body.category-howto .content h2:has(+ *) ~ h2 ~ * { border-left: none; padding-left: 0; margin-left: 0; }
/* Terminal-style code blocks — green $ prompt, monospaced, dark surface */
body.category-howto .content pre {
background: #1a1a1e;
color: #d4e4d4;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-radius: 8px;
position: relative;
padding-left: 2.6rem;
}
body.category-howto .content pre::before {
content: '$';
position: absolute;
left: 1.1rem; top: 1.15rem;
color: var(--accent);
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.86rem;
opacity: 0.85;
}
body.category-howto .content pre code { color: inherit; }
/* ── LANDSCAPE: editorial briefing with H3 player cards ─ */
body.category-landscape .content h3 {
/* Each H3 in landscape = a "player" in the field — give it a card frame */
margin-top: 2.5rem;
padding: 14px 18px 4px;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 4%, transparent);
border-radius: 0 8px 8px 0;
font-family: var(--font-display);
font-size: 1.18rem;
}
body.category-landscape .content h3 + p {
margin-top: 0;
padding: 0 18px 14px;
background: color-mix(in srgb, var(--accent) 4%, transparent);
border-left: 3px solid var(--accent);
margin-left: 0;
border-radius: 0 0 8px 0;
}
/* Pull-quote treatment for any standalone blockquote */
body.category-landscape .content blockquote {
font-size: 1.2rem;
line-height: 1.5;
max-width: 90%;
margin: 2rem auto;
text-align: center;
border-left: none;
border-top: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
background: transparent;
border-radius: 0;
padding: 1.5rem 1rem;
font-style: italic;
}
body.category-landscape .content blockquote::before {
display: none;
}
/* ── COMPARISON: lab-report tables with winner badges ─ */
body.category-comparison .content {
font-feature-settings: 'tnum' on, 'ss01'; /* tabular numerals for tables */
}
body.category-comparison .content table {
font-size: 0.92rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.06);
}
body.category-comparison .content th {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-surface));
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
font-weight: 700;
}
body.category-comparison .content td:first-child {
font-weight: 600;
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
/* The first H3 inside a comparison report often names the recommended pick */
body.category-comparison .content h3:first-of-type::after {
content: 'Pick';
display: inline-block;
margin-left: 10px;
padding: 2px 10px;
background: var(--accent);
color: #fff;
font-family: var(--font-body);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
border-radius: 999px;
vertical-align: middle;
}
/* ── PRODUCT: spec-sheet cards for each H3 ─────────── */
body.category-product .content h3 {
/* Each product gets a spec-card frame — bordered, slight bg lift */
margin-top: 2.4rem;
padding: 16px 18px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
background: var(--bg-surface);
border-radius: 10px;
display: flex; align-items: baseline; gap: 10px;
font-family: var(--font-display);
letter-spacing: -0.01em;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
body.category-product .content h3::after {
/* small "spec" tag on each product heading */
content: 'SPEC';
margin-left: auto;
font-family: var(--font-body);
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
padding: 3px 8px;
border-radius: 4px;
}
body.category-product .content h3 + p,
body.category-product .content h3 + ul,
body.category-product .content h3 + table {
margin-top: 0.8rem;
padding-left: 4px;
}
/* How-to category */
.category-howto .content h2 {
counter-increment:step-counter;
}
.category-howto .content h2::before {
content:counter(step-counter);
display:inline-flex; align-items:center; justify-content:center;
width:28px; height:28px; border-radius:50%;
background:var(--accent); color:#fff; font-size:0.8em; font-weight:700;
margin-right:10px; flex-shrink:0;
}
.category-howto .content { counter-reset:step-counter; }
.category-howto .content blockquote {
border-left:3px solid var(--accent); background:color-mix(in srgb, var(--accent) 8%, transparent);
padding:12px 16px; border-radius:0 6px 6px 0; margin:1em 0;
}
.category-howto .content blockquote strong:first-child {
display:inline-block; margin-bottom:4px; text-transform:uppercase;
font-size:0.82em; letter-spacing:0.5px;
}
.category-howto .content h2#quick-guide + ol,
.category-howto .content h2#quick-guide ~ ol:first-of-type {
background:color-mix(in srgb, var(--accent) 8%, transparent);
border:1px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-radius:8px; padding:14px 14px 14px 32px; font-size:0.95em; line-height:1.8;
}
.category-howto .content h2#quick-guide {
counter-increment:none;
}
.category-howto .content h2#quick-guide::before {
content:'\26A1'; background:none; width:auto; height:auto; margin-right:6px;
}
</style>
</head>
<body class="category-howto">
<!-- Toolbar: Export + Restore hidden images -->
<div class="toolbar">
<div class="dropdown">
<button id="btn-export" title="Export">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Export ▾
</button>
<div class="dropdown-menu" id="export-menu">
<button id="btn-pdf">Save as PDF</button>
<button id="btn-html">Download HTML</button>
</div>
</div>
</div>
<div class="hero">
<div class="hero-label">Odysseus — Deep Research Report</div>
<h1>Guide Technique Complet pour Recréer l’Éditeur Notion</h1>
</div>
<div class="hero-image" data-img-url="https://www.notion.com/front-static/meta/teams-and-agents.jpg"><img src="https://www.notion.com/front-static/meta/teams-and-agents.jpg" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
<div class="stats-bar">
<div class="stat"><span class="stat-value">572.8s</span> Duration</div>
<div class="stat"><span class="stat-value">3</span> Rounds</div>
<div class="stat"><span class="stat-value">7</span> Queries</div>
<div class="stat"><span class="stat-value">22</span> URLs Analyzed</div>
<div class="stat"><span class="stat-value">deepseek-v4-pro</span> Model</div>
<div class="stat"><span class="stat-value">searxng</span> Search</div>
</div>
<div class="layout">
<aside class="toc-sidebar">
<nav>
<a href="#quick-guide" class="depth-3">Quick Guide</a>
<a href="#pr-requis" class="depth-3">Prérequis</a>
<a href="#tape-1-mod-liser-l-arbre-de-blocs" class="depth-2">Étape 1 : Modéliser l’arbre de blocs</a>
<a href="#tape-2-rendre-les-blocs-et-g-rer-l-dition" class="depth-2">Étape 2 : Rendre les blocs et gérer l’édition</a>
<a href="#tape-3-la-commande-slash-et-le-menu-de-transformation" class="depth-2">Étape 3 : La commande slash et le menu de transformation</a>
<a href="#tape-4-formatage-inline-et-bloc-les-quatre-m-thodes" class="depth-2">Étape 4 : Formatage inline et bloc – les quatre méthodes</a>
<a href="#tape-5-glisser-d-poser-et-gestion-de-l-arborescence" class="depth-2">Étape 5 : Glisser-déposer et gestion de l’arborescence</a>
<a href="#tape-6-design-system-visuel-les-tokens-css" class="depth-2">Étape 6 : Design System visuel : les tokens CSS</a>
<a href="#tape-7-blocs-avanc-s-m-dias-bases-de-donn-es-et-vues" class="depth-2">Étape 7 : Blocs avancés – médias, bases de données et vues</a>
<a href="#tape-8-collaboration-temps-r-el-et-synchronisation" class="depth-2">Étape 8 : Collaboration temps réel et synchronisation</a>
<a href="#tape-9-export-et-interop-rabilit-markdown" class="depth-2">Étape 9 : Export et interopérabilité Markdown</a>
<a href="#common-mistakes" class="depth-3">Common Mistakes</a>
<a href="#conclusion" class="depth-3 active">Conclusion</a>
</nav>
</aside>
<main class="content">
<p><strong>Difficulté :</strong> Expert · <strong>Temps estimé :</strong> 3 à 6 mois pour un clone complet (2 à 4 semaines pour un prototype avec les blocs de base)</p>
<h3 id="quick-guide">Quick Guide</h3>
<ol>
<li>Définir le modèle de données orienté bloc avec un arbre parent-enfant.</li>
<li>Mettre en place un moteur de rendu par bloc, piloté par le contenu et les propriétés.</li>
<li>Implémenter la commande slash (<code>/</code>) et le menu contextuel de transformation.</li>
<li>Gérer le formatage texte via quatre modes : saisie Markdown WYSIWYG, raccourcis clavier, barre d’outils flottante, commandes de couleur.</li>
<li>Intégrer le glisser-déposer avec indices fractionnels et indentation visuelle.</li>
<li>Appliquer rigoureusement le Design System de Notion (couleurs, typographie, espacements, ombres, animations).</li>
<li>Construire les blocs avancés : bases de données, vues (table, board, calendar…), propriétés typées.</li>
<li>Ajouter la collaboration temps réel avec CRDT et indexation fractionnelle.</li>
</ol>
<h3 id="pr-requis">Prérequis</h3>
<ul>
<li>Maîtrise de <strong>React</strong> (ou vue similaire) et de la gestion d’état complexe.</li>
<li>Connaissance des <strong>éditeurs de texte</strong> (contentEditable, IME, décorations) ou d’une bibliothèque comme Slate.js / ProseMirror.</li>
<li>Compréhension des <strong>algorithmes CRDT</strong> (Yjs, Automerge) et des <strong>index fractionnels</strong> pour le classement.</li>
<li>Expérience avec le <strong>glisser-déposer HTML5</strong>, les listes virtualisées et les animations CSS.</li>
<li>Accès aux ressources de conception : tokens de couleurs, typographie, ombres de Notion (cf. sources).</li>
</ul>
<blockquote>
<p><strong>Tip:</strong> Téléchargez les polices NotionInter et JetBrains Mono pour reproduire exactement le rendu typographique.</p>
</blockquote>
<hr>
<h2 id="tape-1-mod-liser-l-arbre-de-blocs">Étape 1 : Modéliser l’arbre de blocs</h2>
<p>L’unité fondamentale de l’éditeur Notion est le <strong>bloc</strong>. Chaque bloc possède un identifiant unique (<code>id</code>), un <code>type</code> (paragraphe, titre, image, etc.), des <code>props</code> spécifiques (texte, URL, fichier, propriétés de base de données…), un objet <code>style</code> (couleur de texte, fond) et un tableau ordonné d’IDs d’enfants (<code>children</code>). L’ordre global des blocs au sein d’une page est maintenu par un <strong>index fractionnel</strong> – une chaîne comme <code>"a0"</code>, <code>"a1b"</code> – qui permet d’insérer de nouveaux blocs entre deux positions sans réindexer l’ensemble (<a href="https://www.techinterview.org/post/3233474953/frontend-system-design-notion-block-editor/" rel="noopener noreferrer" target="_blank">Frontend System Design</a>).</p>
<p>Définissez votre structure de données de base :</p>
<div class="code"><pre><span></span><code><span class="kd">interface</span><span class="w"> </span><span class="nx">Block</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">id</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">;</span>
<span class="w"> </span><span class="kr">type</span><span class="o">:</span><span class="w"> </span><span class="s1">'paragraph'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s1">'heading_1'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s1">'heading_2'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="p">...</span><span class="w"> </span><span class="p">;</span>
<span class="w"> </span><span class="nx">props</span><span class="o">:</span><span class="w"> </span><span class="kt">any</span><span class="p">;</span><span class="w"> </span><span class="c1">// texte, url, etc.</span>
<span class="w"> </span><span class="nx">style</span><span class="o">?:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">textColor?</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">,</span><span class="w"> </span><span class="nx">bgColor?</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="p">};</span>
<span class="w"> </span><span class="nx">parentId</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="kc">null</span><span class="p">;</span>
<span class="w"> </span><span class="nx">children</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">[];</span>
<span class="w"> </span><span class="nx">fractionalIndex</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">;</span><span class="w"> </span><span class="c1">// position dans la liste ordonnée</span>
<span class="p">}</span>
</code></pre></div>
<p>Chaque page est un bloc racine dont les enfants sont les blocs de premier niveau. L’indentation est matérialisée par la relation parent-enfant : un bloc indenté (appuyer sur Tab) devient enfant du bloc précédent, ce qui lui confère un retrait visuel et une ligne verticale de liaison.</p>
<blockquote>
<p><strong>Warning:</strong> Ne stockez pas le texte formaté en HTML. Conservez une représentation interne avec les marqueurs Markdown bruts (<code>**gras**</code>) que vous masquez à l’affichage pour obtenir l’effet WYSIWYG immédiat. Cela garantit une exportation Markdown propre.</p>
</blockquote>
<hr>
<h2 id="tape-2-rendre-les-blocs-et-g-rer-l-dition">Étape 2 : Rendre les blocs et gérer l’édition</h2>
<figure class="section-image" data-img-url="https://www.techinterview.org/wp-content/themes/astra-child/assets/brand/og-default.png"><img src="https://www.techinterview.org/wp-content/themes/astra-child/assets/brand/og-default.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Chaque type de bloc est un composant React capable de passer du mode <strong>lecture</strong> au mode <strong>édition</strong> (en utilisant <code>contentEditable</code> pour la portion texte). Pour les blocs de texte, le composant affiche le contenu avec les décorations de formatage (gras, italique, code) tout en masquant les symboles Markdown sous-jacents.</p>
<ul>
<li><strong>Titres (H1, H2, H3)</strong> : la détection du marqueur (<code>#</code>, <code>##</code>, <code>###</code>) déclenche la transformation immédiate du bloc en titre. Visuellement, le texte est rendu avec les spécifications typographiques : H1 à 40px Bold, H2 à 32px Bold, H3 à 26px Semibold (<a href="https://www.dembrandt.com/explorer/notion" rel="noopener noreferrer" target="_blank">Notion Design Tokens</a>).</li>
<li><strong>Listes (à puces, numérotées, to-do)</strong> : un pattern en début de ligne (<code>*</code>, <code>-</code>, <code>+</code>, <code>1.</code>, <code>[]</code>) transforme le bloc. La numérotation est automatique, gérée par un compteur dans le composant parent.</li>
<li><strong>Togglers</strong> : le premier enfant sert de contenu dépliable. L’icône de flèche pivote avec une animation CSS de 200ms <code>ease-in-out</code>.</li>
</ul>
<p>La <strong>virtualisation</strong> est critique : seuls les blocs dans la zone visible de la fenêtre sont montés dans le DOM. Utilisez une bibliothèque comme <code>react-window</code> en estimant la hauteur de chaque type de bloc. Les blocs hors écran sont remplacés par des espaceurs vides pour éviter les défilements saccadés.</p>
<blockquote>
<p><strong>Tip:</strong> Intégrez un état «&nbsp;placeholder&nbsp;» : quand un bloc vide a le focus, affichez un texte grisé du type «&nbsp;Titre 1&nbsp;» ou «&nbsp;Liste à puces…&nbsp;» en utilisant la couleur <code>#9B9A97</code>.</p>
</blockquote>
<hr>
<h2 id="tape-3-la-commande-slash-et-le-menu-de-transformation">Étape 3 : La commande slash et le menu de transformation</h2>
<p>Taper <code>/</code> dans un bloc vide ou en début de ligne après un espace ouvre un menu flottant ancré sous le curseur. Ce menu liste tous les types de blocs et actions (insertion de médias, changement de couleur, bouton de modèle…). Son implémentation doit :</p>
<ul>
<li>Afficher une popup avec largeur ~300px, ombre portée <code>md</code> (<code>0 4px 16px rgba(0,0,0,0.08)</code>), rayon de bordure 8px.</li>
<li>Permettre une recherche incrémentale qui met en surbrillance les correspondances.</li>
<li>Catégoriser les résultats (Basique, Média, Base de données, Avancé) avec des icônes, reproduisant exactement l’interface de Notion.</li>
</ul>
<p>Le choix d’un élément dans le menu transforme le bloc courant ou en insère un nouveau. La commande slash accepte aussi des alias de couleur : <code>/red</code>, <code>/blue</code>, <code>/default</code>, etc., pour modifier directement le fond ou le texte du bloc (<a href="https://thomasjfrank.com/learn-notion/markdown-commands-editing-shortcuts/" rel="noopener noreferrer" target="_blank">Markdown Commands &amp; Editing Shortcuts</a>).</p>
<blockquote>
<p><strong>Warning:</strong> Gérez la composition IME pour les langues asiatiques. Ne déclenchez pas la commande slash en plein milieu d’une saisie en cours de composition, sinon vous briserez l’expérience.</p>
</blockquote>
<hr>
<h2 id="tape-4-formatage-inline-et-bloc-les-quatre-m-thodes">Étape 4 : Formatage inline et bloc – les quatre méthodes</h2>
<figure class="section-image" data-img-url="https://thomasjfrank.com/wp-content/uploads/2021/04/Notions-Markdown-Writing-System-Notion-Fundamentals-with-Thomas-Frank.jpg"><img src="https://thomasjfrank.com/wp-content/uploads/2021/04/Notions-Markdown-Writing-System-Notion-Fundamentals-with-Thomas-Frank.jpg" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Notion offre quatre manières d’appliquer le formatage, que vous devez toutes reproduire pour une expérience identique.</p>
<ol>
<li><strong>Saisie Markdown WYSIWYG</strong> : Lorsque l’utilisateur tape <code>**texte**</code>, l’éditeur capture les délimiteurs, les masque immédiatement et applique le style gras. La même logique vaut pour <code>_italique_</code>, <code>`code`</code>, <code>~barré~</code>. Pour le bloc, <code>#</code>, <code>*</code>, <code>&gt;</code>, <code>---</code> transforment le type du bloc dès l’espace suivant.</li>
<li><strong>Raccourcis clavier</strong> : <code>Cmd/Ctrl+B</code> pour le gras, <code>I</code> pour l’italique, <code>U</code> pour le souligné, <code>Shift+S</code> pour le barré, <code>E</code> pour le code inline, <code>K</code> pour le lien. Les touches doivent fonctionner en mode édition inline et basculer l’état du style.</li>
<li><strong>Barre d’outils flottante</strong> : Elle apparaît automatiquement lors d’une sélection de texte, avec une animation de fondu et de glissement (200ms <code>ease-in-out</code>). Elle contient des boutons pour le gras, l’italique, le lien, les couleurs, la mention, la date, et la transformation du bloc. Le bouton de couleur ouvre un sélecteur en grille des 10 familles de couleurs, à la fois pour le texte et le fond.</li>
<li><strong>Commande slash de couleur</strong> : <code>/turn</code> ouvre ce même sélecteur ; <code>/default</code> supprime toutes les couleurs du bloc.</li>
</ol>
<p>Implémentez cette double posture en maintenant une structure de marqueurs internes (offsets de début et de fin avec type de style) et en les rendant sous forme de balises <code>&lt;strong&gt;</code>, <code>&lt;em&gt;</code>, etc., lorsque le bloc est affiché. Cela permet l’export Markdown sans perte (<a href="https://thomasjfrank.com/a-guide-to-editing-and-formatting-text-in-notion-notion-fundamentals/" rel="noopener noreferrer" target="_blank">Guide to Editing and Formatting Text</a>).</p>
<hr>
<h2 id="tape-5-glisser-d-poser-et-gestion-de-l-arborescence">Étape 5 : Glisser-déposer et gestion de l’arborescence</h2>
<p>Le drag-and-drop repose sur une <strong>poignée 6 points</strong> (⋮⋮) qui apparaît au survol d’un bloc avec une transition de fond <code>rgba(55,53,47,0.08)</code> en 100ms. L’icône est grise (<code>#9B9A97</code>) et devient plus foncée au survol. L’utilisateur peut :</p>
<ul>
<li><strong>Déplacer verticalement</strong> un bloc en le faisant glisser.</li>
<li><strong>Imbriquer</strong> : lors du drop, si la souris est décalée horizontalement vers la droite, le bloc devient enfant du bloc au-dessus. L’UI affiche une prévisualisation de l’indentation (ligne verticale <code>#E3E2E0</code>) pendant le glissement.</li>
<li><strong>Déplacer plusieurs blocs</strong> : sélection multiple en faisant glisser sur les poignées ou avec <code>Shift+clic</code> ; les enfants sont entraînés automatiquement.</li>
</ul>
<p>L’animation de drop est optimiste : l’état local de l’arbre est modifié immédiatement, puis la nouvelle position (index fractionnel) est synchronisée avec le serveur via l’algorithme CRDT. L’index fractionnel permet d’insérer un bloc entre deux blocs sans conflit, même en édition concurrente. Chaque bloc se voit attribuer un index de type chaîne généré par un compteur distribué ou un algorithme dédié (<a href="https://www.techinterview.org/post/3233474953/frontend-system-design-notion-block-editor/" rel="noopener noreferrer" target="_blank">Frontend System Design</a>).</p>
<p>Les raccourcis clavier <code>Tab</code> et <code>Shift+Tab</code> modifient le niveau d’indentation de manière identique, en mettant à jour la relation parent-enfant et l’index.</p>
<hr>
<h2 id="tape-6-design-system-visuel-les-tokens-css">Étape 6 : Design System visuel : les tokens CSS</h2>
<figure class="section-image" data-img-url="https://app.notion.com/images/meta/default.png"><img src="https://app.notion.com/images/meta/default.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Pour que l’interface soit indiscernable de Notion, vous devez reproduire rigoureusement son langage visuel. Implémentez les variables CSS suivantes (extraits des guidelines de conception) (<a href="https://designmd.cc/benchmarks/notion" rel="noopener noreferrer" target="_blank">Notion Design Tokens, Typography &amp; CSS Variables</a> ; <a href="https://www.designlang.app/gallery/notion-so/brand.html" rel="noopener noreferrer" target="_blank">notion.so · brand guidelines</a>) :</p>
<p><strong>Couleurs</strong>
- Primaire : <code>#0075DE</code>, accent <code>#213183</code>.
- Échelle de gris : <code>#FBFBFA</code> (fond page), <code>#FFFFFF</code> (fond éditeur), <code>#E3E2E0</code> (bordures légères), <code>#9B9A97</code> (texte placeholder), <code>#37352F</code> (texte principal).
- Palette vive (10 familles) pour le texte et le fond des blocs.</p>
<p><strong>Typographie</strong>
- Police : NotionInter (ou Inter par défaut), 400, 500, 600, 700.
- Tailles : corps 16px/1.5, code 14px/1.4 JetBrains Mono, titres comme décrit plus haut.</p>
<p><strong>Espacement</strong>
- Unité de base 2px. Espacements clés : 20, 24, 32, 40, 48, 56, 60px. Indentation : 24px par niveau. Marges verticales entre blocs : 4px, avec 8px avant/après les titres.</p>
<p><strong>Rayons de bordure</strong> : 4px (blocs), 8px (popovers), 12px (modales), 16px (images).</p>
<p><strong>Ombres portées</strong>
- <code>sm</code>: <code>0 2px 8px rgba(0,0,0,0.06)</code>
- <code>md</code>: <code>0 4px 16px rgba(0,0,0,0.08)</code> (menu slash, barre flottante)
- <code>lg</code>: <code>0 8px 32px rgba(0,0,0,0.12)</code> (modales)</p>
<p><strong>Animations</strong> : durée 100ms pour les hover, 200ms pour l’apparition des menus, courbe <code>ease-in-out</code>. Les états de focus utilisent un contour bleu de 2px.</p>
<hr>
<h2 id="tape-7-blocs-avanc-s-m-dias-bases-de-donn-es-et-vues">Étape 7 : Blocs avancés – médias, bases de données et vues</h2>
<p>En plus des blocs de texte, Notion intègre un riche éventail de blocs de contenu (<a href="https://www.notion.com/help/guides/types-of-content-blocks" rel="noopener noreferrer" target="_blank">Types of content blocks</a>) :</p>
<ul>
<li><strong>Image, Vidéo, Audio, Fichier</strong> : upload ou lien externe. Le rendu s’adapte à la largeur de la page, avec un rayon de 8px.</li>
<li><strong>Signet web</strong> : carte d’aperçu enrichie (titre, description, favicon) générée lors du collage d’une URL.</li>
<li><strong>Embeds</strong> : plus de 500 services (Figma, Google Maps, Tweet…) intégrés via iframe lorsque l’URL est collée directement.</li>
<li><strong>Équation LaTeX</strong> : inline <code>$E=mc^2$</code> ou bloc <code>$$...$$</code>, rendu avec KaTeX. Le rendu bascule du code source à la formule une fois le focus perdu.</li>
<li><strong>Bouton de modèle</strong> : génère une structure de blocs prédéfinie en un clic.</li>
</ul>
<p>Les <strong>bases de données</strong> sont des blocs complexes. Chaque base est une collection de pages (items) dotées de <strong>propriétés typées</strong> (Texte, Nombre, Sélection, Date, Formule, Relation…). Elles supportent cinq <strong>vues</strong> interchangeables : Table, Board (Kanban), Calendar, Gallery, List. Chaque vue possède ses propres filtres, tris, groupements et options de layout (<a href="https://www.notion.com/help/views-filters-and-sorts" rel="noopener noreferrer" target="_blank">Views, filters, sorts &amp; groups</a>). La vue Table, par exemple, doit permettre le gel de colonnes, le redimensionnement, et le tri par clic d’en-tête.</p>
<p>Pour implémenter les bases de données, créez un sous-système de stockage propre avec un schéma flexible et une couche de rendu dédiée à chaque vue. La synchronisation des cellules s’appuie sur le même mécanisme CRDT que le texte.</p>
<blockquote>
<p><strong>Tip:</strong> Pour la coloration conditionnelle des propriétés de type Sélection, utilisez la couleur de l’étiquette définie par l’utilisateur (ex. «&nbsp;Urgent&nbsp;» en rouge).</p>
</blockquote>
<hr>
<h2 id="tape-8-collaboration-temps-r-el-et-synchronisation">Étape 8 : Collaboration temps réel et synchronisation</h2>
<figure class="section-image" data-img-url="https://images.ctfassets.net/spoqsaf9291f/61wFeznsNSl6ckZEpkgQJi/ce1b0c68dcd36d029590e151defd1bdb/types-of-content-blocks.png"><img src="https://images.ctfassets.net/spoqsaf9291f/61wFeznsNSl6ckZEpkgQJi/ce1b0c68dcd36d029590e151defd1bdb/types-of-content-blocks.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Le multi-utilisateur est au cœur de Notion. Chaque modification (frappe, déplacement, changement de style) doit être immédiatement visible par les autres. Votre architecture doit inclure :</p>
<ul>
<li><strong>CRDT de séquence</strong> pour le texte à l’intérieur de chaque bloc. Utilisez Yjs, qui fournit un type <code>Y.Text</code> gérant automatiquement la fusion des éditions concurrentes.</li>
<li><strong>CRDT d’arbre</strong> pour la hiérarchie : les déplacements de blocs sont traités via des index fractionnels stockés dans une <code>Y.Map</code>. Yjs supporte les structures arborescentes, mais vous pouvez aussi le faire manuellement avec des listes ordonnées et des index fractionnels répliqués.</li>
<li><strong>Curseurs distants</strong> : chaque collaborateur envoie sa position de curseur sous forme d’annotation (bloc cible + offset). Vous les affichez avec un chevron coloré portant le nom de la personne, intégré dans un calque au-dessus du texte.</li>
<li><strong>Rendu optimiste</strong> : toute action locale est appliquée immédiatement dans l’état React, puis envoyée au serveur, qui la diffuse aux autres pairs. En cas de conflit, le CRDT assure la convergence finale sans blocage.</li>
</ul>
<p>La latence perçue est nulle et les conflits de formatage ou de déplacement sont résolus automatiquement. Pour minimiser la bande passante, ne synchronisez que les blocs modifiés et utilisez une connexion WebSocket persistante.</p>
<hr>
<h2 id="tape-9-export-et-interop-rabilit-markdown">Étape 9 : Export et interopérabilité Markdown</h2>
<p>L’éditeur doit maintenir une double représentation : le texte brut avec marqueurs Markdown (pour l’export et la copie) et le contenu formaté affiché. Lors de l’export d’une page au format Markdown, régénérez les marqueurs à partir des styles internes (ex. <code>**gras**</code>). L’import Markdown suit le chemin inverse : parsez les marqueurs et créez l’arbre de blocs correspondant. Le moteur de parsing peut être le même que celui utilisé pour la saisie WYSIWYG.</p>
<hr>
<h3 id="common-mistakes">Common Mistakes</h3>
<ul>
<li><strong>Ignorer l’IME</strong> : ne pas tester la composition de caractères (japonais, chinois) conduit à des bugs où la touche «&nbsp;/&nbsp;» déclenche le menu slash pendant la saisie.</li>
<li><strong>Utiliser <code>innerHTML</code></strong> : pour l’édition, manipuler directement le DOM brise le modèle de données et empêche le suivi des décorations. Préférez un éditeur contrôlé avec des offsets.</li>
<li><strong>Oublier la virtualisation</strong> : sans liste virtualisée, une page de 1000 blocs sera inutilisable.</li>
<li><strong>Glisser-déposer non résilient</strong> : ne pas utiliser d’index fractionnel entraîne des conflits lors des déplacements concurrents. L’implémentation naïve avec des entiers oblige à réindexer toute la liste.</li>
<li><strong>Ne pas respecter l’atomicité des blocs</strong> : si un lien embarqué est traité comme du texte inline, la carte d’aperçu ne sera pas générée. Chaque type de contenu doit être un bloc dédié avec ses propres handlers.</li>
<li><strong>Design system incomplet</strong> : omettre les palettes de couleur ou les jetons d’espacement rend l’interface visuellement décalée. Reproduisez jusqu’aux ombres <code>xs</code> et aux transitions de 50ms.</li>
</ul>
<h3 id="conclusion">Conclusion</h3>
<p>Reconstruire l’éditeur Notion à l’identique exige une maîtrise fine de l’architecture en blocs, de l’édition WYSIWYG, du design system et de la collaboration temps réel. Ce guide a détaillé chaque couche : du modèle de données à l’interface utilisateur, en passant par la commande slash, le glisser-déposer, les bases de données, et les tokens CSS. En appliquant rigoureusement ces spécifications – des index fractionnels aux animations de 200ms, des 10 familles de couleurs aux propriétés typées des bases – un développeur peut produire un clone fidèle, capable de servir des millions d’utilisateurs simultanés. Le code, l’état d’esprit et les détails visuels constituent un tout : seule une attention obsessionnelle à chaque interaction permet de capturer l’essence de Notion.</p>
<p><em>Sources consultées : Thomas Frank’s Notion guides, Notion Help Center, DesignMD tokens, Frontend System Design interview, Dembrandt et Designlang.</em></p>
<div class="sources-panel">
<details>
<summary>Sources (16)</summary>
<div class="sources-list">
<a href="https://www.notion.com/" target="_blank" rel="noopener noreferrer"><span class="snum">1.</span><span>The AI workspace that works for you. | Notion</span><span class="sdomain">notion.com</span></a>
<a href="https://www.notion.com/help/guides/types-of-content-blocks" target="_blank" rel="noopener noreferrer"><span class="snum">2.</span><span>Types of content blocks - Notion</span><span class="sdomain">notion.com</span></a>
<a href="https://app.notion.com/login" target="_blank" rel="noopener noreferrer"><span class="snum">3.</span><span>Notion - Log in</span><span class="sdomain">app.notion.com</span></a>
<a href="https://thomasfrank.notion.site/Notion-Block-Reference-All-of-Notion-s-Blocks-8b40147600284c60b6f708e38f16ee68" target="_blank" rel="noopener noreferrer"><span class="snum">4.</span><span>Notion Block Reference - All of Notion's Blocks | Notion</span><span class="sdomain">thomasfrank.notion.site</span></a>
<a href="https://thomasjfrank.com/learn-notion/markdown-commands-editing-shortcuts/" target="_blank" rel="noopener noreferrer"><span class="snum">5.</span><span>Markdown Commands &amp; Editing Shortcuts - Thomas Frank</span><span class="sdomain">thomasjfrank.com</span></a>
<a href="https://www.notion.so/min-0/" target="_blank" rel="noopener noreferrer"><span class="snum">6.</span><span>Notion</span><span class="sdomain">notion.so</span></a>
<a href="https://thomasjfrank.com/a-guide-to-editing-and-formatting-text-in-notion-notion-fundamentals/" target="_blank" rel="noopener noreferrer"><span class="snum">7.</span><span>A Guide to Editing and Formatting Text in Notion - Notion Fundamentals</span><span class="sdomain">thomasjfrank.com</span></a>
<a href="https://ones.com/blog/mastering-notions-block-drag-and-drop-reordering/" target="_blank" rel="noopener noreferrer"><span class="snum">8.</span><span>Revolutionize Your Workflow: Mastering Notion's Block Drag-and-Drop Reordering</span><span class="sdomain">ones.com</span></a>
<a href="https://www.techinterview.org/post/3233474953/frontend-system-design-notion-block-editor/" target="_blank" rel="noopener noreferrer"><span class="snum">9.</span><span>Frontend System Design: Build Notion's Block-Based Editor</span><span class="sdomain">techinterview.org</span></a>
<a href="https://www.notion.com/help/views-filters-and-sorts" target="_blank" rel="noopener noreferrer"><span class="snum">10.</span><span>Views, filters, sorts &amp; groups - Notion Help Center</span><span class="sdomain">notion.com</span></a>
<a href="https://designmd.cc/benchmarks/notion" target="_blank" rel="noopener noreferrer"><span class="snum">11.</span><span>Notion Design Tokens, Typography &amp; CSS Variables — DesignMD</span><span class="sdomain">designmd.cc</span></a>
<a href="https://www.designlang.app/gallery/notion-so/brand.html" target="_blank" rel="noopener noreferrer"><span class="snum">12.</span><span>notion.so · brand guidelines</span><span class="sdomain">designlang.app</span></a>
<a href="https://www.dembrandt.com/explorer/notion" target="_blank" rel="noopener noreferrer"><span class="snum">13.</span><span>Notion Design Tokens: Fonts, Colors &amp; Typography | Dembrandt</span><span class="sdomain">dembrandt.com</span></a>
<a href="https://www.notion.com/templates/design-system-01" target="_blank" rel="noopener noreferrer"><span class="snum">14.</span><span>Design System Template by NotoMantra | Notion Marketplace</span><span class="sdomain">notion.com</span></a>
<a href="https://cssawwwards.com/blog/css-design-tokens-guide-2026" target="_blank" rel="noopener noreferrer"><span class="snum">15.</span><span>CSS Design Token Generator: Build a Complete Design System in Minutes (2026) | CSSAWWWARDS — CSS Tools Directory</span><span class="sdomain">cssawwwards.com</span></a>
<a href="https://www.notion.com/help/database-properties" target="_blank" rel="noopener noreferrer"><span class="snum">16.</span><span>Database properties – Notion Help Center</span><span class="sdomain">notion.com</span></a>
</div>
</details>
</div>
<div class="chat-cta"><button id="btn-chat-about" class="chat-cta-btn" data-research-id="rp-913e21f18cbd"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg><span>Discuss</span></button><div class="chat-cta-hint">Opens a new chat with this report as context.</div></div>
</main>
</div>
<div class="report-footer">
Generated by Odysseus Deep Research · July 09, 2026 at 12:14
</div>
<script>
(function() {
// ESC closes the report tab. window.close() works when the tab was
// opened via window.open() (which is how the panel launches it). If the
// browser blocks self-close (rare — e.g. report opened by direct URL),
// fall back to history.back() so ESC still feels responsive.
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape' || e.defaultPrevented) return;
// Don't hijack ESC while typing in a field or with an open dropdown.
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
var menu = document.getElementById('export-menu');
if (menu && menu.classList.contains('open')) { menu.classList.remove('open'); return; }
try { window.close(); } catch (err) {}
// window.close() is a no-op when the tab wasn't script-opened; in that
// case fall back to navigation so the key isn't ignored.
setTimeout(function() { if (!window.closed) history.back(); }, 50);
});
// Export dropdown toggle
var exportBtn = document.getElementById('btn-export');
var exportMenu = document.getElementById('export-menu');
exportBtn.addEventListener('click', function(e) {
e.stopPropagation();
exportMenu.classList.toggle('open');
});
document.addEventListener('click', function() { exportMenu.classList.remove('open'); });
// Save as PDF (browser print)
document.getElementById('btn-pdf').addEventListener('click', function() {
exportMenu.classList.remove('open');
window.print();
});
// Download HTML
document.getElementById('btn-html').addEventListener('click', function() {
exportMenu.classList.remove('open');
var blob = new Blob([document.documentElement.outerHTML], { type: 'text/html' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = document.title.replace(/[^a-z0-9]+/gi, '-').substring(0, 60) + '.html';
a.click();
});
// Per-image hide — fades the image out, then POSTs to the backend so
// future renders of this report skip the URL. Falls back to a silent
// no-op if there's no session_id (e.g. the report was opened from a
// saved-HTML download where the backend isn't reachable).
var __sessionId = "rp-913e21f18cbd";
// Unused scraped images — the reroll pool. Each is used at most once.
var __spareImages = ["https://www.notion.comhttps://img.youtube.com/vi/Nx114VWepoI/maxresdefault.jpg", "https://designmd.cc/benchmarks/notion.jpg", "https://www.dembrandt.com/dembrandt-og.png", "https://www.notion.com/en-us/front-api/og-image/templates/design-system-01", "https://cssawwwards.com/og/og-image.png", "https://images.ctfassets.net/spoqsaf9291f/2C9kI0iZiHR4iAHoV6AIRP/030a34f9a7ae10d7ea78274319a5a18d/Database_properties_-_hero.png"];
// Persist a rejected URL so future renders skip it.
function __persistHide(url) {
if (!__sessionId || !url) return;
fetch('/api/research/' + encodeURIComponent(__sessionId) + '/hide-image', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
}).catch(function(err) { console.warn('hide-image POST failed', err); });
}
// Once the pool is empty, there's nothing to swap to — hide all reroll btns.
function __syncRerollAvailability() {
if (__spareImages.length === 0) {
document.querySelectorAll('.img-reroll-btn').forEach(function(b) { b.disabled = true; });
}
}
document.querySelectorAll('.img-hide-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var wrap = btn.closest('[data-img-url]');
if (!wrap) return;
var url = wrap.dataset.imgUrl;
wrap.classList.add('fading');
setTimeout(function() { wrap.remove(); }, 280);
__persistHide(url);
});
});
// Reroll — swap the current image for the next unused scraped one, and
// persist-hide the rejected URL so it won't resurface on reload.
document.querySelectorAll('.img-reroll-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
// Per-button busy flag — a rapid double-click would otherwise both
// shift the spare pool, but only the second probe's image would land,
// silently consuming the first one. Bail until finish() clears it.
if (btn.dataset._busy === '1') return;
if (__spareImages.length === 0) { btn.disabled = true; return; }
var wrap = btn.closest('[data-img-url]');
if (!wrap) return;
var img = wrap.querySelector('img');
if (!img) return;
btn.dataset._busy = '1';
var oldUrl = wrap.dataset.imgUrl;
var newUrl = __spareImages.shift();
btn.classList.add('spinning');
// Swap once the new image has loaded (or failed) to avoid a flash of empty.
var probe = new Image();
var done = false;
var finish = function(ok) {
if (done) return; done = true;
btn.classList.remove('spinning');
delete btn.dataset._busy;
if (ok) {
img.src = newUrl;
wrap.dataset.imgUrl = newUrl;
__persistHide(oldUrl);
} else {
// Bad candidate — persist-hide it so it can't resurface on reload,
// then try the next spare if any remain. Busy flag already cleared
// so the synthetic click below proceeds.
__persistHide(newUrl);
if (__spareImages.length) btn.click();
}
__syncRerollAvailability();
};
probe.onload = function() { finish(true); };
probe.onerror = function() { finish(false); };
probe.src = newUrl;
});
});
__syncRerollAvailability();
// "Show hidden (N)" button — clears the hidden_images list on the
// server, then reloads the page so all images come back.
var restoreBtn = document.getElementById('btn-restore-images');
if (restoreBtn && __sessionId) {
restoreBtn.addEventListener('click', function() {
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring…';
fetch('/api/research/' + encodeURIComponent(__sessionId) + '/unhide-images', {
method: 'POST', credentials: 'same-origin',
}).then(function() { window.location.reload(); })
.catch(function(err) {
restoreBtn.disabled = false;
restoreBtn.textContent = 'Failed — retry?';
console.warn('unhide-images POST failed', err);
});
});
}
// TOC: explicit smooth-scroll handler (some browsers/anchor plugins
// bypass the CSS `scroll-behavior: smooth` rule on hash clicks).
// Also keeps the URL hash updated and toggles an `.active` highlight.
var tocLinks = document.querySelectorAll('.toc-sidebar nav a[href^="#"]');
tocLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
var id = link.getAttribute('href').slice(1);
var target = document.getElementById(id);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', '#' + id);
});
});
// Highlight the TOC entry that matches whichever heading is currently
// closest to the top of the viewport. IntersectionObserver keeps it
// cheap (no scroll listener spam).
var tocMap = {};
tocLinks.forEach(function(link) {
tocMap[link.getAttribute('href').slice(1)] = link;
});
var activeId = null;
function setActive(id) {
if (id === activeId) return;
if (activeId && tocMap[activeId]) tocMap[activeId].classList.remove('active');
if (id && tocMap[id]) tocMap[id].classList.add('active');
activeId = id;
}
var headings = document.querySelectorAll('.content h2[id], .content h3[id]');
if (headings.length && 'IntersectionObserver' in window) {
var visible = new Set();
var io = new IntersectionObserver(function(entries) {
entries.forEach(function(en) {
if (en.isIntersecting) visible.add(en.target.id);
else visible.delete(en.target.id);
});
// Pick the visible heading that's furthest down in document order
// before the current scroll — i.e. the section we're reading.
var current = null;
for (var i = 0; i < headings.length; i++) {
if (visible.has(headings[i].id)) { current = headings[i].id; break; }
}
if (current) setActive(current);
}, { rootMargin: '-10% 0px -75% 0px', threshold: 0 });
headings.forEach(function(h) { io.observe(h); });
}
// Chat about this research — POST to spinoff and redirect to the new chat
var chatBtn = document.getElementById('btn-chat-about');
if (chatBtn) {
chatBtn.addEventListener('click', function() {
var researchId = chatBtn.dataset.researchId;
if (!researchId) return;
var origLabel = chatBtn.innerHTML;
chatBtn.disabled = true;
chatBtn.innerHTML = '<span>Creating chat…</span>';
fetch('/api/research/spinoff/' + encodeURIComponent(researchId), {
method: 'POST', credentials: 'same-origin',
}).then(function(res) {
if (!res.ok) {
return res.json().then(function(d) {
throw new Error(d && d.detail ? d.detail : ('HTTP ' + res.status));
}, function() { throw new Error('HTTP ' + res.status); });
}
return res.json();
}).then(function(data) {
if (!data || !data.session_id) {
throw new Error('Server did not return a session id');
}
var url = '/#' + data.session_id;
var opened = false;
// The report typically opens in a new tab — if we have access to the
// original Odysseus tab, navigate it and close this report tab so the
// user lands directly in the new chat.
try {
if (window.opener && !window.opener.closed) {
window.opener.location.href = url;
window.opener.location.reload();
window.opener.focus();
opened = true;
window.close();
}
} catch (e) { /* cross-origin or detached opener — fall through */ }
if (!opened) {
// No opener (report was opened directly via URL) — open the chat in a
// new tab so the report stays available.
var w = window.open(url, '_blank');
if (w) {
chatBtn.disabled = false;
chatBtn.innerHTML = '<span>Chat opened in new tab</span>';
} else {
// Popup blocked — navigate this tab as a last resort.
window.location.href = url;
window.location.reload();
}
}
}).catch(function(err) {
chatBtn.disabled = false;
chatBtn.innerHTML = origLabel;
alert('Could not start follow-up chat: ' + err.message);
});
});
}
})();
// Colorize comparison table cells
if (document.body.classList.contains('category-comparison')) {
const pos = /^(yes|excellent|best|great|strong|fast|high|superior|winner|free|unlimited|native|full|advanced|built[- ]in|✓|✅|⭐)/i;
const neg = /^(no|none|poor|weak|slow|low|limited|lacking|missing|basic|minimal|✗|❌|N\/A$)/i;
const mid = /^(moderate|average|fair|partial|some|decent|okay|mixed|varies|depends)/i;
document.querySelectorAll('.content table td').forEach(td => {
if (td.cellIndex === 0) return;
const t = td.textContent.trim();
if (pos.test(t)) td.classList.add('cmp-pos');
else if (neg.test(t)) td.classList.add('cmp-neg');
else if (mid.test(t)) td.classList.add('cmp-mid');
});
}
</script>
</body></html>