feat: editor improvements and architectural grid prototype

Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -0,0 +1,25 @@
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
export const BLOCK_DRAG_HANDLE_ID = 'notion-block-drag-handle'
export const BLOCK_DRAG_HANDLE_WIDTH = 20
/** Même logique de résolution de bloc que tiptap-extension-global-drag-handle */
export function resolveBlockAtDragHandle(editor: Editor): { node: PMNode; pos: number } | null {
const handle = document.getElementById(BLOCK_DRAG_HANDLE_ID)
if (!handle || editor.isDestroyed) return null
const rect = handle.getBoundingClientRect()
const coords = editor.view.posAtCoords({
left: rect.right + 50 + BLOCK_DRAG_HANDLE_WIDTH,
top: rect.top + rect.height / 2,
})
if (coords?.pos == null) return null
const $pos = editor.state.doc.resolve(coords.pos)
const blockPos = $pos.depth > 1 ? $pos.before($pos.depth) : coords.pos
const node = editor.state.doc.nodeAt(blockPos)
if (!node) return null
return { node, pos: blockPos }
}

View File

@@ -0,0 +1,122 @@
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
const ID_ATTR = 'data-id'
const BLOCK_TYPES_WITH_ID = new Set([
'paragraph',
'heading',
'blockquote',
'bulletList',
'orderedList',
'taskList',
'codeBlock',
])
export function generateBlockId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
function readNodeId(node: PMNode | null | undefined): string | null {
const id = node?.attrs[ID_ATTR]
return typeof id === 'string' && id.length > 0 ? id : null
}
function findDataIdInSubtree(node: PMNode): string | null {
const direct = readNodeId(node)
if (direct) return direct
let found: string | null = null
node.descendants((child) => {
if (found) return false
found = readNodeId(child)
return !found
})
return found
}
function findDataIdFromDom(editor: Editor, blockPos: number): string | null {
const dom = editor.view.nodeDOM(blockPos)
if (!(dom instanceof HTMLElement)) return null
const direct = dom.getAttribute(ID_ATTR)
if (direct) return direct
const nested = dom.querySelector(`[${ID_ATTR}]`)
const nestedId = nested?.getAttribute(ID_ATTR)
return nestedId || null
}
function findTextBlockToAssign(
editor: Editor,
blockPos: number,
blockNode: PMNode,
): { pos: number; node: PMNode } | null {
if (BLOCK_TYPES_WITH_ID.has(blockNode.type.name)) {
return { pos: blockPos, node: blockNode }
}
let target: { pos: number; node: PMNode } | null = null
editor.state.doc.nodesBetween(blockPos, blockPos + blockNode.nodeSize, (node, pos) => {
if (target) return false
if (node.isTextblock && BLOCK_TYPES_WITH_ID.has(node.type.name)) {
target = { pos, node }
return false
}
return true
})
return target
}
/** Cherche un data-id sur le bloc résolu (nœud, descendants, ancêtres, DOM). */
export function findBlockReferenceId(
editor: Editor,
blockPos: number,
blockNode: PMNode | null,
): string | null {
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
if (!node) return null
const fromSubtree = findDataIdInSubtree(node)
if (fromSubtree) return fromSubtree
const $pos = editor.state.doc.resolve(Math.min(blockPos + 1, editor.state.doc.content.size))
for (let depth = $pos.depth; depth > 0; depth--) {
const id = readNodeId($pos.node(depth))
if (id) return id
}
return findDataIdFromDom(editor, blockPos)
}
/** Assigne un data-id si absent, retourne l'id utilisable pour la référence. */
export function ensureBlockReferenceId(
editor: Editor,
blockPos: number,
blockNode: PMNode | null,
): string | null {
const existing = findBlockReferenceId(editor, blockPos, blockNode)
if (existing) return existing
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
if (!node || blockPos < 0) return null
const target = findTextBlockToAssign(editor, blockPos, node)
if (!target) return null
const newId = generateBlockId()
editor.view.dispatch(
editor.state.tr.setNodeMarkup(target.pos, undefined, {
...target.node.attrs,
[ID_ATTR]: newId,
}),
)
return newId
}

View File

@@ -0,0 +1,30 @@
/** Copie texte — writeText async + repli synchrone execCommand. */
export async function copyTextToClipboard(text: string): Promise<boolean> {
if (typeof window === 'undefined') return false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// repli ci-dessous
}
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.left = '-9999px'
ta.style.top = '0'
document.body.appendChild(ta)
ta.focus()
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}

View File

@@ -0,0 +1,100 @@
export type DatabaseAuthor = { id: string; name: string }
export type DatabaseBook = {
id: string
title: string
author: string
cover: string
tag: string
}
export type DatabaseView = 'table' | 'card'
export type DatabaseBlockData = {
dbId: string
dbView: DatabaseView
dbAuthors: DatabaseAuthor[]
dbBooks: DatabaseBook[]
}
const DEFAULT_COVERS = [
'https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=400',
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=400',
'https://images.unsplash.com/photo-1506318137071-a8e063b4bec0?auto=format&fit=crop&q=80&w=400',
'https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=400',
]
export function randomDefaultCover(): string {
return DEFAULT_COVERS[Math.floor(Math.random() * DEFAULT_COVERS.length)]
}
export function createDefaultDatabaseBlockData(): DatabaseBlockData {
return {
dbId: `authors-works-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID().slice(0, 8) : Date.now()}`,
dbView: 'table',
dbAuthors: [
{ id: 'a1', name: 'Jules Verne' },
{ id: 'a2', name: 'Liu Cixin' },
],
dbBooks: [
{
id: 'bk1',
title: 'Twenty Thousand Leagues Under The Sea',
author: 'Jules Verne',
cover: DEFAULT_COVERS[0],
tag: 'Aventure',
},
{
id: 'bk2',
title: 'The Three-Body Problem',
author: 'Liu Cixin',
cover: DEFAULT_COVERS[1],
tag: 'Hard SF',
},
{
id: 'bk3',
title: 'The Wandering Earth',
author: 'Liu Cixin',
cover: DEFAULT_COVERS[2],
tag: 'SF Spatial',
},
],
}
}
export function serializeDatabaseBlockData(data: DatabaseBlockData): {
dbId: string
dbView: DatabaseView
dbAuthorsJson: string
dbBooksJson: string
} {
return {
dbId: data.dbId,
dbView: data.dbView,
dbAuthorsJson: JSON.stringify(data.dbAuthors),
dbBooksJson: JSON.stringify(data.dbBooks),
}
}
export function parseDatabaseBlockAttrs(attrs: {
dbId?: string
dbView?: string
dbAuthorsJson?: string
dbBooksJson?: string
}): DatabaseBlockData {
const fallback = createDefaultDatabaseBlockData()
let authors = fallback.dbAuthors
let books = fallback.dbBooks
try {
if (attrs.dbAuthorsJson) authors = JSON.parse(attrs.dbAuthorsJson)
} catch { /* keep fallback */ }
try {
if (attrs.dbBooksJson) books = JSON.parse(attrs.dbBooksJson)
} catch { /* keep fallback */ }
return {
dbId: attrs.dbId || fallback.dbId,
dbView: attrs.dbView === 'card' ? 'card' : 'table',
dbAuthors: Array.isArray(authors) ? authors : fallback.dbAuthors,
dbBooks: Array.isArray(books) ? books : fallback.dbBooks,
}
}

View File

@@ -0,0 +1,19 @@
import type { EditorState } from '@tiptap/pm/state'
import type { Node as PMNode } from '@tiptap/pm/model'
function isEmptyParagraph(node: PMNode): boolean {
if (node.type.name !== 'paragraph') return false
return node.textContent.trim() === '' && node.content.size <= 1
}
/** Paragraphe vide sous le curseur (critère US-3 Smart Paste). */
export function getEmptyParagraphAtSelection(
state: EditorState,
): { pos: number; node: PMNode } | null {
const { $from, empty } = state.selection
if (!empty) return null
if (!isEmptyParagraph($from.parent)) return null
const pos = $from.before($from.depth)
return { pos, node: $from.parent }
}

View File

@@ -0,0 +1,17 @@
import GlobalDragHandle from 'tiptap-extension-global-drag-handle'
import AutoJoiner from 'tiptap-extension-auto-joiner'
import { BLOCK_DRAG_HANDLE_ID, BLOCK_DRAG_HANDLE_WIDTH } from './block-at-drag-handle'
/** Extensions drag handle Novel / Notion — une poignée globale, position fixed */
export const globalDragHandleExtensions = [
GlobalDragHandle.configure({
dragHandleWidth: BLOCK_DRAG_HANDLE_WIDTH,
scrollTreshold: 100,
dragHandleSelector: `#${BLOCK_DRAG_HANDLE_ID}`,
excludedTags: [],
customNodes: [],
}),
AutoJoiner.configure({
elementsToJoin: ['bulletList', 'orderedList'],
}),
]

View File

@@ -0,0 +1,67 @@
export type ParsedBlockReference = {
sourceNoteId: string
blockId: string
raw: string
}
export type StoredBlockReference = ParsedBlockReference & {
blockContent?: string
sourceNoteTitle?: string
}
export const LAST_BLOCK_REF_SESSION_KEY = 'memento:lastBlockRef'
/** Référence bloc copiée via « Copier la référence » : /home?openNote=…#block-… */
export function parseBlockReferenceFromText(text: string): ParsedBlockReference | null {
const trimmed = text.trim()
if (!trimmed || trimmed.includes('\n')) return null
const match = trimmed.match(/openNote=([^&#\s]+)(?:[^#]*?)#block-([a-zA-Z0-9_-]+)/i)
if (!match) return null
try {
return {
sourceNoteId: decodeURIComponent(match[1]),
blockId: match[2],
raw: trimmed,
}
} catch {
return null
}
}
export function rememberBlockReference(
raw: string,
extras?: { blockContent?: string; sourceNoteTitle?: string },
) {
if (typeof sessionStorage === 'undefined') return
const parsed = parseBlockReferenceFromText(raw)
if (!parsed) return
const payload: StoredBlockReference = {
...parsed,
...(extras?.blockContent ? { blockContent: extras.blockContent } : {}),
...(extras?.sourceNoteTitle ? { sourceNoteTitle: extras.sourceNoteTitle } : {}),
}
try {
sessionStorage.setItem(LAST_BLOCK_REF_SESSION_KEY, JSON.stringify(payload))
} catch {
// quota / private mode
}
}
export function recallLastBlockReference(): StoredBlockReference | null {
if (typeof sessionStorage === 'undefined') return null
try {
const stored = sessionStorage.getItem(LAST_BLOCK_REF_SESSION_KEY)
if (!stored) return null
if (stored.startsWith('{')) {
const payload = JSON.parse(stored) as StoredBlockReference
if (payload?.sourceNoteId && payload?.blockId && payload?.raw) return payload
return null
}
const parsed = parseBlockReferenceFromText(stored)
return parsed ?? null
} catch {
return null
}
}

View File

@@ -0,0 +1,39 @@
import { Extension } from '@tiptap/core'
import type { EditorView } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export type SmartPasteHandler = (view: EditorView, event: ClipboardEvent) => boolean
export const smartPastePluginKey = new PluginKey('smartPaste')
declare module '@tiptap/core' {
interface Storage {
smartPaste: {
onPaste: SmartPasteHandler | null
}
}
}
export const SmartPasteExtension = Extension.create({
name: 'smartPaste',
addStorage() {
return {
onPaste: null as SmartPasteHandler | null,
}
},
addProseMirrorPlugins() {
const storage = this.storage
return [
new Plugin({
key: smartPastePluginKey,
props: {
handlePaste(view, event) {
return storage.onPaste?.(view, event) ?? false
},
},
}),
]
},
})