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:
@@ -5,6 +5,23 @@ export interface ExtractedBlock {
|
||||
content: string
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** Contenu texte d'un bloc identifié par data-id (sans filtre longueur min). */
|
||||
export function extractBlockContentById(html: string, blockId: string): string | null {
|
||||
if (!html || !blockId) return null
|
||||
const regex = new RegExp(
|
||||
`<(?:p|h[1-6]|blockquote|li)[^>]*data-id="${escapeRegExp(blockId)}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote|li)>`,
|
||||
'i',
|
||||
)
|
||||
const match = regex.exec(html)
|
||||
if (!match) return null
|
||||
const content = stripHtmlToPlainText(match[1])
|
||||
return content.length > 0 ? content : null
|
||||
}
|
||||
|
||||
export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
|
||||
const blocks: ExtractedBlock[] = []
|
||||
const regex = /<(?:p|h[1-6]|blockquote|li)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li)>/gi
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
18
memento-note/lib/note-peek-sync.ts
Normal file
18
memento-note/lib/note-peek-sync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Ouvre la note source en aperçu split (panneau gauche) sans changer d’onglet. */
|
||||
export const NOTE_PEEK_OPEN_EVENT = 'memento-note-peek-open'
|
||||
export const NOTE_PEEK_CLOSE_EVENT = 'memento-note-peek-close'
|
||||
|
||||
export type NotePeekOpenDetail = {
|
||||
noteId: string
|
||||
blockId?: string
|
||||
}
|
||||
|
||||
export function openNotePeek(detail: NotePeekOpenDetail) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_PEEK_OPEN_EVENT, { detail }))
|
||||
}
|
||||
|
||||
export function closeNotePeek() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_PEEK_CLOSE_EVENT))
|
||||
}
|
||||
@@ -3,6 +3,25 @@ import type { Note } from '@/lib/types'
|
||||
const MD_IMG = /!\[[^\]]*\]\(([^)\s]+)\)/g
|
||||
const HTML_IMG = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
|
||||
|
||||
/** SSR-safe HTML entity decode so excerpt text matches server and client. */
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/(?:�*39;|')/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code: string) => {
|
||||
const n = Number(code)
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : `&#${code};`
|
||||
})
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => {
|
||||
const n = parseInt(hex, 16)
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : `&#x${hex};`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text preview for list view (light markdown stripping).
|
||||
*/
|
||||
@@ -58,6 +77,7 @@ export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
|
||||
let raw = note.content || ''
|
||||
raw = raw.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
raw = raw.replace(/<[^>]+>/g, ' ')
|
||||
raw = decodeHtmlEntities(raw)
|
||||
return stripMarkdownPreview(raw, maxLen)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { NotesLayoutMode, NotesViewType } from '@/components/notes-list-views'
|
||||
import type { NotesLayoutMode } from '@/components/notes-list-views'
|
||||
|
||||
export const NOTES_LAYOUT_COOKIE = 'memento-notes-layout'
|
||||
export const NOTES_VIEW_TYPE_COOKIE = 'memento-notes-view-type'
|
||||
export const NOTES_LAYOUT_STORAGE_KEY = 'memento-notes-layout'
|
||||
export const NOTES_VIEW_TYPE_STORAGE_KEY = 'memento-notes-view-type'
|
||||
|
||||
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table', 'kanban', 'gallery']
|
||||
const VIEW_TYPE_VALUES: NotesViewType[] = ['notes', 'tasks']
|
||||
|
||||
export function parseNotesLayoutMode(value: string | undefined | null): NotesLayoutMode {
|
||||
if (value && (LAYOUT_VALUES as string[]).includes(value)) {
|
||||
@@ -17,19 +14,8 @@ export function parseNotesLayoutMode(value: string | undefined | null): NotesLay
|
||||
return 'list'
|
||||
}
|
||||
|
||||
export function parseNotesViewType(value: string | undefined | null): NotesViewType {
|
||||
if (value && (VIEW_TYPE_VALUES as string[]).includes(value)) return value as NotesViewType
|
||||
return 'notes'
|
||||
}
|
||||
|
||||
export function setNotesLayoutPreference(layout: NotesLayoutMode) {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(NOTES_LAYOUT_STORAGE_KEY, layout)
|
||||
document.cookie = `${NOTES_LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
export function setNotesViewTypePreference(viewType: NotesViewType) {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(NOTES_VIEW_TYPE_STORAGE_KEY, viewType)
|
||||
document.cookie = `${NOTES_VIEW_TYPE_COOKIE}=${viewType}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
10
memento-note/lib/use-hydrated.ts
Normal file
10
memento-note/lib/use-hydrated.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/** True only after the client has hydrated (false on server + first client paint). */
|
||||
export function useHydrated(): boolean {
|
||||
return useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user