feat: architectural grid editor fullPage + slash commands + doc info panel + AI title
This commit is contained in:
83
memento-note/lib/apply-document-theme.ts
Normal file
83
memento-note/lib/apply-document-theme.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Valeurs persistées pour User.theme / localStorage `theme-preference`.
|
||||
* Une seule source de vérité pour le DOM : évite les écarts entre header, settings et hydratation.
|
||||
*/
|
||||
export const THEME_IDS = [
|
||||
'light',
|
||||
'dark',
|
||||
'auto',
|
||||
'sepia',
|
||||
'midnight',
|
||||
'rose',
|
||||
'green',
|
||||
'lavender',
|
||||
'sand',
|
||||
'ocean',
|
||||
'sunset',
|
||||
'blue',
|
||||
] as const
|
||||
|
||||
export type ThemeId = (typeof THEME_IDS)[number]
|
||||
|
||||
const NAMED_PALETTE_IDS: ThemeId[] = [
|
||||
'sepia',
|
||||
'midnight',
|
||||
'rose',
|
||||
'green',
|
||||
'lavender',
|
||||
'sand',
|
||||
'ocean',
|
||||
'sunset',
|
||||
'blue',
|
||||
]
|
||||
|
||||
export function isThemeId(value: string | null | undefined): value is ThemeId {
|
||||
return value !== undefined && value !== null && (THEME_IDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Corrige les anciennes valeurs (ex. formulaire « slate ») vers un thème supporté. */
|
||||
export function normalizeThemeId(raw: string | null | undefined): ThemeId {
|
||||
if (!raw) return 'light'
|
||||
if (raw === 'slate') return 'light'
|
||||
if (isThemeId(raw)) return raw
|
||||
return 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
|
||||
* - `light` → papier par défaut (`:root`), pas de `data-theme`
|
||||
* - `dark` → sombre global (`.dark`)
|
||||
* - `auto` → `.dark` si prefers-color-scheme: dark
|
||||
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
|
||||
*/
|
||||
export function applyDocumentTheme(theme: string): void {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const t = normalizeThemeId(theme)
|
||||
const root = document.documentElement
|
||||
root.classList.remove('dark')
|
||||
root.removeAttribute('data-theme')
|
||||
|
||||
if (t === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
root.classList.add('dark')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (t === 'dark') {
|
||||
root.classList.add('dark')
|
||||
return
|
||||
}
|
||||
|
||||
if (t === 'light') {
|
||||
return
|
||||
}
|
||||
|
||||
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
|
||||
root.setAttribute('data-theme', t)
|
||||
if (t === 'midnight') {
|
||||
root.classList.add('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
59
memento-note/lib/client/excalidraw-export-image.ts
Normal file
59
memento-note/lib/client/excalidraw-export-image.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||
import type { BinaryFiles } from '@excalidraw/excalidraw/types'
|
||||
|
||||
/**
|
||||
* Parses canvas JSON from DB — either a bare elements array or { elements, files }.
|
||||
* Returns null for PPTX placeholders or invalid JSON.
|
||||
*/
|
||||
export function parseExcalidrawSceneFromCanvasData(
|
||||
dataStr: string
|
||||
): { elements: readonly ExcalidrawElement[]; files: BinaryFiles | null } | null {
|
||||
if (!dataStr?.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(dataStr)
|
||||
if (parsed && parsed.type === 'pptx') return null
|
||||
if (Array.isArray(parsed)) {
|
||||
return { elements: parsed as ExcalidrawElement[], files: null }
|
||||
}
|
||||
if (parsed?.elements && Array.isArray(parsed.elements)) {
|
||||
const files =
|
||||
parsed.files && typeof parsed.files === 'object' ? (parsed.files as BinaryFiles) : null
|
||||
return { elements: parsed.elements as readonly ExcalidrawElement[], files }
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Excali scene to PNG in the browser (uses Excalidraw export helpers).
|
||||
*/
|
||||
export async function exportExcalidrawSceneToPngBlob(dataStr: string): Promise<Blob | null> {
|
||||
const scene = parseExcalidrawSceneFromCanvasData(dataStr)
|
||||
if (!scene || scene.elements.length === 0) return null
|
||||
|
||||
const { exportToBlob, getNonDeletedElements, MIME_TYPES } = await import('@excalidraw/excalidraw')
|
||||
const elements = getNonDeletedElements(scene.elements as ExcalidrawElement[])
|
||||
if (!elements.length) return null
|
||||
|
||||
try {
|
||||
return await exportToBlob({
|
||||
elements,
|
||||
files: scene.files,
|
||||
mimeType: MIME_TYPES.png,
|
||||
exportPadding: 24,
|
||||
maxWidthOrHeight: 2400,
|
||||
appState: {
|
||||
exportBackground: true,
|
||||
exportWithDarkMode: false,
|
||||
viewBackgroundColor: '#ffffff',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[excalidraw-export-image] exportToBlob failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
const MD_IMG = /!\[[^\]]*\]\(([^)\s]+)\)/g
|
||||
const HTML_IMG = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
|
||||
|
||||
/**
|
||||
* Plain-text preview for list view (light markdown stripping).
|
||||
*/
|
||||
@@ -31,3 +36,27 @@ export function getNoteDisplayTitle(note: { title: string | null; content: strin
|
||||
const preview = stripMarkdownPreview(note.content || '', 100)
|
||||
return preview || untitled
|
||||
}
|
||||
|
||||
export function getNoteFeedImage(note: Note): string | null {
|
||||
const first = note.images?.[0]?.trim()
|
||||
if (first) return first
|
||||
|
||||
const content = note.content || ''
|
||||
MD_IMG.lastIndex = 0
|
||||
const md = MD_IMG.exec(content)
|
||||
if (md?.[1]) return md[1]
|
||||
|
||||
HTML_IMG.lastIndex = 0
|
||||
const html = HTML_IMG.exec(content)
|
||||
if (html?.[1]) return html[1]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Plain excerpt for collection cards (HTML + markdown noise stripped). */
|
||||
export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
|
||||
let raw = note.content || ''
|
||||
raw = raw.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
raw = raw.replace(/<[^>]+>/g, ' ')
|
||||
return stripMarkdownPreview(raw, maxLen)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
export function getThemeScript(theme: string = 'light') {
|
||||
return `
|
||||
(function() {
|
||||
try {
|
||||
var localTheme = localStorage.getItem('theme-preference');
|
||||
var theme = localTheme || '${theme}';
|
||||
var root = document.documentElement;
|
||||
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
/**
|
||||
* Script inline exécuté avant l’hydratation : même logique que `applyDocumentTheme`.
|
||||
*/
|
||||
import { normalizeThemeId } from './apply-document-theme'
|
||||
|
||||
if (theme === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
} else if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (theme === 'light') {
|
||||
// do nothing
|
||||
} else {
|
||||
root.setAttribute('data-theme', theme);
|
||||
if (theme === 'midnight') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
})();
|
||||
`
|
||||
export function getThemeScript(serverTheme: string = 'light') {
|
||||
const fallback = normalizeThemeId(serverTheme)
|
||||
return `
|
||||
(function() {
|
||||
try {
|
||||
var fallback = ${JSON.stringify(fallback)};
|
||||
var stored = localStorage.getItem('theme-preference');
|
||||
var raw = stored || fallback;
|
||||
if (raw === 'slate') raw = 'light';
|
||||
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
|
||||
var theme = allowed[raw] ? raw : 'light';
|
||||
var root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (theme === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
|
||||
} else if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (theme === 'light') {
|
||||
/* :root papier */
|
||||
} else {
|
||||
root.setAttribute('data-theme', theme);
|
||||
if (theme === 'midnight') root.classList.add('dark');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
})();
|
||||
`.trim()
|
||||
}
|
||||
|
||||
@@ -154,10 +154,10 @@ export const LABEL_COLORS = {
|
||||
icon: 'text-teal-500 dark:text-teal-400'
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-sky-100 dark:bg-sky-900/40',
|
||||
text: 'text-sky-700 dark:text-sky-300',
|
||||
border: 'border-sky-200 dark:border-sky-800',
|
||||
icon: 'text-sky-500 dark:text-sky-400'
|
||||
bg: 'bg-slate-100 dark:bg-slate-900/40',
|
||||
text: 'text-slate-700 dark:text-slate-300',
|
||||
border: 'border-slate-200 dark:border-slate-700',
|
||||
icon: 'text-slate-600 dark:text-slate-400'
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||
@@ -207,9 +207,9 @@ export const NOTE_COLORS = {
|
||||
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
|
||||
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
|
||||
bg: 'bg-slate-50 dark:bg-slate-950/30',
|
||||
hover: 'hover:bg-slate-100 dark:hover:bg-slate-950/50',
|
||||
card: 'bg-slate-50 dark:bg-slate-950/30 border-slate-200 dark:border-slate-800/50'
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50 dark:bg-purple-950/30',
|
||||
|
||||
Reference in New Issue
Block a user