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:
25
memento-note/lib/editor/block-at-drag-handle.ts
Normal file
25
memento-note/lib/editor/block-at-drag-handle.ts
Normal 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 }
|
||||
}
|
||||
122
memento-note/lib/editor/block-reference-id.ts
Normal file
122
memento-note/lib/editor/block-reference-id.ts
Normal 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
|
||||
}
|
||||
30
memento-note/lib/editor/copy-text-to-clipboard.ts
Normal file
30
memento-note/lib/editor/copy-text-to-clipboard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
100
memento-note/lib/editor/database-block-types.ts
Normal file
100
memento-note/lib/editor/database-block-types.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
19
memento-note/lib/editor/empty-paragraph-at-selection.ts
Normal file
19
memento-note/lib/editor/empty-paragraph-at-selection.ts
Normal 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 }
|
||||
}
|
||||
17
memento-note/lib/editor/global-drag-handle-extension.ts
Normal file
17
memento-note/lib/editor/global-drag-handle-extension.ts
Normal 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'],
|
||||
}),
|
||||
]
|
||||
67
memento-note/lib/editor/parse-block-reference.ts
Normal file
67
memento-note/lib/editor/parse-block-reference.ts
Normal 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
|
||||
}
|
||||
}
|
||||
39
memento-note/lib/editor/smart-paste-extension.ts
Normal file
39
memento-note/lib/editor/smart-paste-extension.ts
Normal 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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user