From 0e29db2adf1c5bf09a2b223c84897f4d07490676 Mon Sep 17 00:00:00 2001 From: Ekrem EDALI <83559434+eedali@users.noreply.github.com> Date: Thu, 28 May 2026 18:26:34 +0300 Subject: [PATCH 01/11] feat: implement modular note editor system with BlockInsertionPanel and support for multiple editor engines --- package-lock.json | 150 ++++++ package.json | 8 + src/components/notes/BlockInsertionPanel.tsx | 226 +++++++++ src/components/notes/EditorJsEditor.tsx | 500 +++++++++++++++++++ src/components/notes/NoteEditor.tsx | 275 +--------- src/components/notes/SettingsPanel.tsx | 18 + src/components/notes/TiptapEditor.tsx | 268 ++++++++++ src/components/notes/editorConverters.ts | 226 +++++++++ src/i18n/translations.ts | 120 +++++ src/index.css | 63 +++ src/store/useAppStore.ts | 32 +- update_translations.cjs | 44 ++ 12 files changed, 1670 insertions(+), 260 deletions(-) create mode 100644 src/components/notes/BlockInsertionPanel.tsx create mode 100644 src/components/notes/EditorJsEditor.tsx create mode 100644 src/components/notes/TiptapEditor.tsx create mode 100644 src/components/notes/editorConverters.ts create mode 100644 update_translations.cjs diff --git a/package-lock.json b/package-lock.json index bb399de..cbab581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,14 @@ "name": "kobar", "version": "1.0.26", "dependencies": { + "@editorjs/checklist": "^1.6.0", + "@editorjs/code": "^2.9.4", + "@editorjs/delimiter": "^1.4.2", + "@editorjs/editorjs": "^2.31.6", + "@editorjs/header": "^2.8.9", + "@editorjs/image": "^2.10.3", + "@editorjs/list": "^2.0.9", + "@editorjs/quote": "^2.7.6", "@floating-ui/dom": "^1.7.6", "@tiptap/extension-image": "^3.20.1", "@tiptap/extension-placeholder": "^3.20.1", @@ -355,6 +363,12 @@ "node": ">=6.9.0" } }, + "node_modules/@codexteam/icons": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.3.3.tgz", + "integrity": "sha512-cp7mkZPgmBuSxigTm3Vb+DtVHYeX7qXfQd7o05vcLD8Ag5WvRlol2QSn5P10k0CDAJwmkH9nQGQLBycErS9lsQ==", + "license": "MIT" + }, "node_modules/@coooookies/windows-smtc-monitor": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@coooookies/windows-smtc-monitor/-/windows-smtc-monitor-1.0.12.tgz", @@ -436,6 +450,130 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@editorjs/caret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@editorjs/caret/-/caret-1.1.0.tgz", + "integrity": "sha512-dzUjrPV7mtqM0HR/7IwT1/1PfyMxUfe5MRXo9joFmBRDIcrQ5ikbqVxSL9+Hy5tQ+BUWBwqLUgX1UKTJylWW9Q==", + "license": "MIT", + "dependencies": { + "@editorjs/dom": "^1.1.0" + } + }, + "node_modules/@editorjs/checklist": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@editorjs/checklist/-/checklist-1.6.0.tgz", + "integrity": "sha512-hRNP36DInr73mSK3noHBQeoQb7DA12ANfqTXufEkTgQzx+k4mRJ0HdeGukTIR4JbwjHJ9ecUBnnQqIEGnxCFEg==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.0" + } + }, + "node_modules/@editorjs/code": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@editorjs/code/-/code-2.9.4.tgz", + "integrity": "sha512-c0zyWodNqjL/0WI67sZvACIOFU9IAHG0UeeIpjss8pZGGNBum+UWkh7nKULK0SYvaOrdPdlWWqjuFU1TFA5jUA==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2" + } + }, + "node_modules/@editorjs/delimiter": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@editorjs/delimiter/-/delimiter-1.4.2.tgz", + "integrity": "sha512-S8q2LpeYdYkVShLp7K8c4HLthDHBevLw+sT+iO0+SH0oMvFmld9SUon3DFzMQ2gG07EOdZGRZ958+sVxyvFjZw==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2" + } + }, + "node_modules/@editorjs/dom": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-1.1.0.tgz", + "integrity": "sha512-aH8OeS3DXCDG2WP6yt/uS6Z9PstrA3Re930boIilxtMdF7Ht+pKJrq3OJnd7OCMPVO/y7UOAmtHNesVx6v4K2g==", + "license": "MIT", + "dependencies": { + "@editorjs/helpers": "^1.2.0" + } + }, + "node_modules/@editorjs/editorjs": { + "version": "2.31.6", + "resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.31.6.tgz", + "integrity": "sha512-j2nxuFCD3DnoJDUD0hPO5tirpuQ6sWmmuHATl3EI7fG1it3Ml7jk0WJtu3E+TRLBd7zfNeJhDThbP3LnBEn2GA==", + "license": "Apache-2.0", + "dependencies": { + "@editorjs/caret": "^1.0.1", + "codex-notifier": "^1.1.2", + "codex-tooltip": "^1.0.5" + } + }, + "node_modules/@editorjs/header": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.9.tgz", + "integrity": "sha512-eBL9/PLDKiE9ADZycmgps3PLz9Lw8w6rPFXr0mfHuhHQI/ynP/q1vLpyruBrRCflTLD2Dlj5nYPoJjL5mP/MFw==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.0.5", + "@editorjs/editorjs": "^2.30.7" + } + }, + "node_modules/@editorjs/header/node_modules/@codexteam/icons": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz", + "integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==", + "license": "MIT" + }, + "node_modules/@editorjs/helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-1.2.2.tgz", + "integrity": "sha512-saDNARvY3xd70H4KE0eBq2hlV0Z7SpfI1/1iVqyVt5PqhlEOIR5rWJFH4c63BHTa6BfiR/+PMl1n7X/X7SlptQ==", + "license": "MIT", + "dependencies": { + "codex-tooltip": "^1.0.6" + } + }, + "node_modules/@editorjs/image": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@editorjs/image/-/image-2.10.3.tgz", + "integrity": "sha512-ekCsGICZOIdghF/U2T34H7CItqaWAoJDXbkRD+x8l/LIo/7Ozf7KovYm21qz+CluArgV4RurVFHqwlz+O0vfJA==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.0" + } + }, + "node_modules/@editorjs/list": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@editorjs/list/-/list-2.0.9.tgz", + "integrity": "sha512-rUTgDSt5wygD3Dp24bNyp6vvye/Xf4UWju0ZuvWeP13Z4cu2z1Jb5JFSTEhCou72XUGuf4xVhtsd8cm/bwUS1g==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2" + } + }, + "node_modules/@editorjs/quote": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@editorjs/quote/-/quote-2.7.6.tgz", + "integrity": "sha512-D01KUMSDj2r+6Z+xjDkQqI+y6URpeHCvj0+P4pah+GtkG040lWjFb2H4pgHFXuol2cbfyAoraYSw85fuPheCvw==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2", + "@editorjs/dom": "^0.0.5" + } + }, + "node_modules/@editorjs/quote/node_modules/@editorjs/dom": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-0.0.5.tgz", + "integrity": "sha512-SZ78Gwpkp3EUhjBIp0lSojeQ35V9acF8SubJsMeOH/vlOUE40GOnvvwWZnF05lO7bIB0dOHhhJy4N7IIAWxP2w==", + "license": "MIT", + "dependencies": { + "@editorjs/helpers": "^0.0.4" + } + }, + "node_modules/@editorjs/quote/node_modules/@editorjs/helpers": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-0.0.4.tgz", + "integrity": "sha512-ieg3dzo2m1/ELze/RMNADiAiC5amXxIlVXoJ5vvXITOu/p/dPsrF+Oi3h5gBYvtGk9vg5LJUSG5YWU0tBUO1tw==", + "license": "MIT" + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -5000,6 +5138,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/codex-notifier": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz", + "integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==", + "license": "MIT" + }, + "node_modules/codex-tooltip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.6.tgz", + "integrity": "sha512-3MVjBjeJFQ4jGlPuYHYV3Yj0wnl1l9ppB5zeIFJeyo8Ala3WlZYtLK5yZEoyND2fxIyVmqsZORCftDGX3vbSGQ==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index 405f77c..e5529b1 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,14 @@ } }, "dependencies": { + "@editorjs/checklist": "^1.6.0", + "@editorjs/code": "^2.9.4", + "@editorjs/delimiter": "^1.4.2", + "@editorjs/editorjs": "^2.31.6", + "@editorjs/header": "^2.8.9", + "@editorjs/image": "^2.10.3", + "@editorjs/list": "^2.0.9", + "@editorjs/quote": "^2.7.6", "@floating-ui/dom": "^1.7.6", "@tiptap/extension-image": "^3.20.1", "@tiptap/extension-placeholder": "^3.20.1", diff --git a/src/components/notes/BlockInsertionPanel.tsx b/src/components/notes/BlockInsertionPanel.tsx new file mode 100644 index 0000000..fba16a9 --- /dev/null +++ b/src/components/notes/BlockInsertionPanel.tsx @@ -0,0 +1,226 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useAppStore } from '../../store/useAppStore'; + +interface BlockOption { + id: string; + icon: string; + label: string; + description: string; +} + +interface BlockInsertionPanelProps { + position: { x: number; y: number }; + onSelect: (blockType: string) => void; + onClose: () => void; + containerRef: React.RefObject; +} + +const BlockInsertionPanel: React.FC = ({ + position, + onSelect, + onClose, + containerRef, +}) => { + const t = useAppStore((state) => state.t); + const design = useAppStore((state) => state.design); + const isMac = useAppStore((state) => state.isMac); + + const [searchQuery, setSearchQuery] = useState(''); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const panelRef = useRef(null); + const searchInputRef = useRef(null); + + const blockOptions: BlockOption[] = [ + { id: 'header', icon: 'title', label: t('blockHeading') || 'Heading', description: 'H1-H6' }, + { id: 'list-unordered', icon: 'format_list_bulleted', label: t('blockBulletedList') || 'Bulleted List', description: '•' }, + { id: 'list-ordered', icon: 'format_list_numbered', label: t('blockNumberedList') || 'Numbered List', description: '1.' }, + { id: 'checklist', icon: 'checklist', label: t('blockChecklist') || 'Checklist', description: '☑' }, + { id: 'code', icon: 'code', label: t('blockCode') || 'Code', description: '<>' }, + { id: 'quote', icon: 'format_quote', label: t('blockQuote') || 'Quote', description: '"' }, + { id: 'image', icon: 'image', label: t('blockImage') || 'Image', description: '📷' }, + { id: 'delimiter', icon: 'horizontal_rule', label: t('blockDivider') || 'Divider', description: '—' }, + ]; + + const filteredOptions = searchQuery.trim() + ? blockOptions.filter(opt => + opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || + opt.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : blockOptions; + + // Auto-focus search input + useEffect(() => { + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + }, []); + + // Close on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + + // Keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex(prev => Math.min(prev + 1, filteredOptions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filteredOptions[highlightedIndex]) { + onSelect(filteredOptions[highlightedIndex].id); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }, [filteredOptions, highlightedIndex, onSelect, onClose]); + + // Reset highlight when filter changes + useEffect(() => { + setHighlightedIndex(0); + }, [searchQuery]); + + // Compute panel position so it doesn't overflow NotePanel edges + const [adjustedPos, setAdjustedPos] = useState(position); + + useEffect(() => { + if (!panelRef.current) { + setAdjustedPos(position); + return; + } + + const panelRect = panelRef.current.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + const notePanel = containerRef.current?.closest('.relative') as HTMLElement | null; + const notePanelRect = notePanel?.getBoundingClientRect(); + + // Use notePanel bounds if available, otherwise viewport + const boundsRight = notePanelRect?.right ?? window.innerWidth; + const boundsBottom = notePanelRect?.bottom ?? window.innerHeight; + const boundsLeft = notePanelRect?.left ?? 0; + const boundsTop = notePanelRect?.top ?? 0; + + const padding = 12; // Minimum distance from NotePanel edges + + let x = position.x; + let y = position.y; + + // Horizontal constraint + if (x + panelRect.width + padding > boundsRight) { + x = boundsRight - panelRect.width - padding; + } + if (x < boundsLeft + padding) { + x = boundsLeft + padding; + } + + // Vertical constraint — if panel would go below bounds, open upward + if (y + panelRect.height + padding > boundsBottom) { + // Open upward from the block + y = position.y - panelRect.height - 8; + } + if (y < boundsTop + padding) { + y = boundsTop + padding; + } + + setAdjustedPos({ x, y }); + }, [position, containerRef]); + + const panelContent = ( +
+ {/* Search input */} +
+ setSearchQuery(e.target.value)} + placeholder="Filter..." + className="w-full bg-transparent text-sm text-slate-300 outline-none placeholder-slate-600 px-2 py-1.5" + /> +
+ + {/* Block options */} +
+ {filteredOptions.length === 0 ? ( +
+ No blocks found +
+ ) : ( + filteredOptions.map((opt, index) => ( + + )) + )} +
+
+ ); + + return createPortal(panelContent, document.body); +}; + +export default BlockInsertionPanel; diff --git a/src/components/notes/EditorJsEditor.tsx b/src/components/notes/EditorJsEditor.tsx new file mode 100644 index 0000000..e470581 --- /dev/null +++ b/src/components/notes/EditorJsEditor.tsx @@ -0,0 +1,500 @@ +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import EditorJS from '@editorjs/editorjs'; +import Header from '@editorjs/header'; +import List from '@editorjs/list'; +import Checklist from '@editorjs/checklist'; +import CodeTool from '@editorjs/code'; +import Quote from '@editorjs/quote'; +import Delimiter from '@editorjs/delimiter'; +import { useAppStore } from '../../store/useAppStore'; +import { validateEditorJsData } from './editorConverters'; +import type { EditorJsOutputData } from './editorConverters'; +import BlockInsertionPanel from './BlockInsertionPanel'; + +interface PlusTrigger { + x: number; + y: number; + blockIndex: number; +} + +const EditorJsEditor: React.FC = React.memo(() => { + const activeNoteId = useAppStore((state) => state.activeNoteId); + const activeNote = useAppStore((state) => state.notes.find(n => n.id === state.activeNoteId)); + const updateNoteTitle = useAppStore((state) => state.updateNoteTitle); + const updateNoteEditorjsData = useAppStore((state) => state.updateNoteEditorjsData); + const t = useAppStore((state) => state.t); + const design = useAppStore((state) => state.design); + + const editorRef = useRef(null); + const holderRef = useRef(null); + const containerRef = useRef(null); + const isMountedRef = useRef(false); + const isSavingRef = useRef(false); + const currentNoteIdRef = useRef(null); + + // Block insertion panel state + const [showInsertPanel, setShowInsertPanel] = useState(false); + const [insertPanelPos, setInsertPanelPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const [plusTrigger, setPlusTrigger] = useState(null); + const [slashBlockIndex, setSlashBlockIndex] = useState(null); + + // Parse stored Editor.js data for a note + const getEditorData = useCallback((note: typeof activeNote): EditorJsOutputData => { + if (!note?.editorjsData) { + return { time: Date.now(), blocks: [{ type: 'paragraph', data: { text: '' } }], version: '2.30.0' }; + } + try { + const parsed = JSON.parse(note.editorjsData); + if (validateEditorJsData(parsed)) { + return parsed; + } + } catch { + // Invalid JSON, return empty + } + return { time: Date.now(), blocks: [{ type: 'paragraph', data: { text: '' } }], version: '2.30.0' }; + }, []); + + // Save current editor content to store + const saveToStore = useCallback(async () => { + if (!editorRef.current || isSavingRef.current) return; + isSavingRef.current = true; + try { + const outputData = await editorRef.current.save(); + const currentId = currentNoteIdRef.current; + if (currentId) { + updateNoteEditorjsData(currentId, JSON.stringify(outputData)); + } + } catch (err) { + console.error('[EditorJS] Save failed:', err); + } finally { + isSavingRef.current = false; + } + }, [updateNoteEditorjsData]); + + // Initialize or re-initialize Editor.js + useEffect(() => { + if (!holderRef.current || !activeNote || activeNote.isSettings) return; + if (activeNote.editorType !== 'editorjs') return; + + // If we're switching to a different note, save the old one first then destroy + const initEditor = async () => { + // Save previous content before switching + if (editorRef.current && currentNoteIdRef.current !== null && currentNoteIdRef.current !== activeNote.id) { + try { + const prevData = await editorRef.current.save(); + updateNoteEditorjsData(currentNoteIdRef.current, JSON.stringify(prevData)); + } catch { /* ignore save errors on switch */ } + } + + // Destroy previous instance + if (editorRef.current) { + try { + editorRef.current.destroy(); + } catch { /* ignore destroy errors */ } + editorRef.current = null; + } + + currentNoteIdRef.current = activeNote.id; + + const data = getEditorData(activeNote); + + // Small delay to ensure DOM element is clean + await new Promise(resolve => setTimeout(resolve, 50)); + + if (!holderRef.current) return; + + // Clear any leftover DOM content + holderRef.current.innerHTML = ''; + + const editor = new EditorJS({ + holder: holderRef.current, + data: data, + placeholder: t('editorjsPlaceholder') || "Press '/' or click '+' to add a block...", + tools: { + header: { + class: Header as any, + config: { + levels: [1, 2, 3, 4, 5, 6], + defaultLevel: 2 + }, + inlineToolbar: true, + }, + list: { + class: List as any, + inlineToolbar: true, + config: { + defaultStyle: 'unordered' + } + }, + checklist: { + class: Checklist as any, + inlineToolbar: true, + }, + code: { + class: CodeTool as any, + }, + quote: { + class: Quote as any, + inlineToolbar: true, + config: { + quotePlaceholder: 'Enter a quote', + captionPlaceholder: 'Quote author', + } + }, + delimiter: { + class: Delimiter as any, + }, + }, + onChange: async () => { + if (isMountedRef.current) { + await saveToStore(); + } + }, + onReady: () => { + isMountedRef.current = true; + // Restore scroll position + if (containerRef.current) { + const savedPos = useAppStore.getState().scrollPositions[`note_editorjs_${activeNote.id}`]; + if (savedPos !== undefined) { + setTimeout(() => { + if (containerRef.current) containerRef.current.scrollTop = savedPos; + }, 50); + } + } + }, + // Minimal config — we handle the toolbox ourselves + minHeight: 100, + }); + + editorRef.current = editor; + }; + + initEditor(); + + return () => { + isMountedRef.current = false; + }; + }, [activeNoteId, activeNote?.editorType]); // Only re-init on tab change or type change + + // Cleanup on unmount + useEffect(() => { + return () => { + if (editorRef.current) { + // Save before destroy + const id = currentNoteIdRef.current; + if (id) { + editorRef.current.save().then(data => { + useAppStore.getState().updateNoteEditorjsData(id, JSON.stringify(data)); + }).catch(() => {}); + } + try { + editorRef.current.destroy(); + } catch { /* ignore */ } + editorRef.current = null; + } + }; + }, []); + + // Track scroll position + useEffect(() => { + if (!containerRef.current || !activeNoteId) return; + const scrollNode = containerRef.current; + + const handleScroll = () => { + useAppStore.getState().setScrollPosition(`note_editorjs_${activeNoteId}`, scrollNode.scrollTop); + }; + + scrollNode.addEventListener('scroll', handleScroll, { passive: true }); + return () => scrollNode.removeEventListener('scroll', handleScroll); + }, [activeNoteId]); + + // Detect "/" key press for slash command and "+" button hover + useEffect(() => { + const holder = holderRef.current; + if (!holder) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + + const range = sel.getRangeAt(0); + const blockEl = (range.startContainer as HTMLElement).closest?.('.ce-block') + || (range.startContainer.parentElement)?.closest?.('.ce-block'); + + if (!blockEl) return; + + // Only trigger on empty blocks or at the start of text + const contentEl = blockEl.querySelector('.ce-paragraph, [contenteditable]'); + const textContent = contentEl?.textContent || ''; + + // If the block is empty or has only whitespace, show the panel + if (textContent.trim() === '' || textContent.trim() === '/') { + e.preventDefault(); + + // Clear the slash character + if (contentEl && contentEl.textContent) { + contentEl.textContent = ''; + } + + const rect = blockEl.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (containerRect) { + // Find block index + const allBlocks = holder.querySelectorAll('.ce-block'); + let blockIndex = 0; + allBlocks.forEach((b, i) => { if (b === blockEl) blockIndex = i; }); + + setSlashBlockIndex(blockIndex); + setInsertPanelPos({ + x: rect.left, + y: rect.bottom + 4, + }); + setShowInsertPanel(true); + } + } + } + + if (e.key === 'Escape' && showInsertPanel) { + setShowInsertPanel(false); + setSlashBlockIndex(null); + } + }; + + holder.addEventListener('keydown', handleKeyDown); + return () => holder.removeEventListener('keydown', handleKeyDown); + }, [showInsertPanel]); + + // Update plus trigger position on mouse move over blocks + useEffect(() => { + const holder = holderRef.current; + if (!holder) return; + + const handleMouseMove = (e: MouseEvent) => { + if (showInsertPanel) return; // Don't show + while panel is open + + const target = e.target as HTMLElement; + const blockEl = target.closest?.('.ce-block'); + + if (!blockEl) { + setPlusTrigger(null); + return; + } + + // Check if this block is empty + const contentEl = blockEl.querySelector('.ce-paragraph, [contenteditable]'); + const textContent = contentEl?.textContent || ''; + + if (textContent.trim() !== '') { + setPlusTrigger(null); + return; + } + + const rect = blockEl.getBoundingClientRect(); + const allBlocks = holder.querySelectorAll('.ce-block'); + let blockIndex = 0; + allBlocks.forEach((b, i) => { if (b === blockEl) blockIndex = i; }); + + setPlusTrigger({ + x: rect.left - 36, + y: rect.top + rect.height / 2 - 12, + blockIndex, + }); + }; + + const handleMouseLeave = () => { + setPlusTrigger(null); + }; + + holder.addEventListener('mousemove', handleMouseMove); + holder.addEventListener('mouseleave', handleMouseLeave); + return () => { + holder.removeEventListener('mousemove', handleMouseMove); + holder.removeEventListener('mouseleave', handleMouseLeave); + }; + }, [showInsertPanel]); + + // Handle plus button click + const handlePlusClick = useCallback(() => { + if (!plusTrigger) return; + + const holder = holderRef.current; + if (!holder) return; + + const blockEl = holder.querySelectorAll('.ce-block')[plusTrigger.blockIndex]; + if (!blockEl) return; + + const rect = blockEl.getBoundingClientRect(); + setSlashBlockIndex(plusTrigger.blockIndex); + setInsertPanelPos({ + x: rect.left, + y: rect.bottom + 4, + }); + setShowInsertPanel(true); + setPlusTrigger(null); + }, [plusTrigger]); + + // Handle block type selection from the insertion panel + const handleBlockSelect = useCallback(async (blockType: string) => { + setShowInsertPanel(false); + setSlashBlockIndex(null); + + if (!editorRef.current) return; + + const editor = editorRef.current; + const currentIndex = slashBlockIndex ?? 0; + + try { + // Insert the block at the current position + switch (blockType) { + case 'header': + await editor.blocks.insert('header', { text: '', level: 2 }, undefined, currentIndex, true); + break; + case 'list-unordered': + await editor.blocks.insert('list', { style: 'unordered', items: [''] }, undefined, currentIndex, true); + break; + case 'list-ordered': + await editor.blocks.insert('list', { style: 'ordered', items: [''] }, undefined, currentIndex, true); + break; + case 'checklist': + await editor.blocks.insert('checklist', { items: [{ text: '', checked: false }] }, undefined, currentIndex, true); + break; + case 'code': + await editor.blocks.insert('code', { code: '' }, undefined, currentIndex, true); + break; + case 'quote': + await editor.blocks.insert('quote', { text: '', caption: '' }, undefined, currentIndex, true); + break; + case 'delimiter': + await editor.blocks.insert('delimiter', {}, undefined, currentIndex, true); + break; + case 'image': + // Trigger file picker for image + triggerImagePicker(); + return; + default: + break; + } + + // Delete the empty placeholder block if we replaced it + if (slashBlockIndex !== null) { + try { + // The new block was inserted, now remove the old empty block that's now shifted + const blockCount = editor.blocks.getBlocksCount(); + if (blockCount > 1) { + // The old block is now at currentIndex + 1 since we inserted before it + editor.blocks.delete(currentIndex + 1); + } + } catch { /* ignore if deletion fails */ } + } + + // Focus the newly inserted block + editor.caret.setToBlock(currentIndex, 'start'); + } catch (err) { + console.error('[EditorJS] Block insert failed:', err); + } + }, [slashBlockIndex]); + + // Image picker for Editor.js + const fileInputRef = useRef(null); + + const triggerImagePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleImageFileSelect = useCallback(async (e: React.ChangeEvent) => { + if (!editorRef.current || !e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = async () => { + const base64 = reader.result as string; + if (editorRef.current) { + const index = slashBlockIndex ?? editorRef.current.blocks.getBlocksCount(); + // Insert a paragraph with image content as a workaround + // Editor.js image tool with base64 inline + await editorRef.current.blocks.insert('paragraph', { + text: `` + }, undefined, index, true); + } + }; + reader.readAsDataURL(file); + e.target.value = ''; + }, [slashBlockIndex]); + + if (!activeNote || activeNote.isSettings) return null; + + return ( +
+ {/* Hidden file input for image selection */} + + + {/* Title area */} +
+ updateNoteTitle(activeNote.id, e.target.value)} + /> +
+ + {/* Editor.js Holder */} +
+ + {/* Plus trigger button (appears on empty block hover) */} + {plusTrigger && !showInsertPanel && createPortal( + , + document.body + )} + + {/* Block Insertion Panel */} + {showInsertPanel && ( + { setShowInsertPanel(false); setSlashBlockIndex(null); }} + containerRef={containerRef} + /> + )} +
+ ); +}); + +export default EditorJsEditor; diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 2a1217e..ed0a2a7 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -1,268 +1,25 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useEditor, EditorContent } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; -import Underline from '@tiptap/extension-underline'; -import Image from '@tiptap/extension-image'; -import Placeholder from '@tiptap/extension-placeholder'; +import React from 'react'; import { useAppStore } from '../../store/useAppStore'; -import { useSpeechToText } from '../../hooks/useSpeechToText'; - +import TiptapEditor from './TiptapEditor'; +import EditorJsEditor from './EditorJsEditor'; + +/** + * NoteEditor Switcher + * Conditionally renders TiptapEditor or EditorJsEditor based on the active note's editorType. + * This component owns zero editor logic — it simply delegates to the correct editor. + */ const NoteEditor: React.FC = React.memo(() => { - const { activeNoteId, updateNoteTitle, t, design, language } = useAppStore(); + const activeNoteId = useAppStore((state) => state.activeNoteId); const activeNote = useAppStore((state) => state.notes.find(n => n.id === activeNoteId)); - const fileInputRef = useRef(null); - const isUpdatingFromStore = useRef(false); - const [, setTick] = useState(0); - - const editor = useEditor({ - extensions: [ - StarterKit.configure({ - bulletList: { keepMarks: true }, - orderedList: { keepMarks: true }, - }), - Underline, - Image.configure({ - inline: false, - allowBase64: true - }), - Placeholder.configure({ - placeholder: 'Start writing your note…', - }), - ], - content: activeNote?.content || '', - editorProps: { - attributes: { - class: 'flex-1 text-slate-300 text-lg leading-relaxed outline-none no-drag-region overflow-y-auto max-w-none', - spellcheck: 'false' - }, - clipboardTextSerializer: (slice) => { - const serializeNode = (node: any, listType: 'bullet' | 'ordered' | null = null, index: number = 0): string => { - if (node.isText) return node.text || ''; - if (node.type.name === 'hardBreak') return '\n'; - - let text = ''; - if (node.type.name === 'bulletList') { - node.forEach((child: any) => { text += serializeNode(child, 'bullet'); }); - return text; - } - if (node.type.name === 'orderedList') { - let i = node.attrs?.start || 1; - node.forEach((child: any) => { - text += serializeNode(child, 'ordered', i); - i++; - }); - return text; - } - if (node.type.name === 'listItem') { - const prefix = listType === 'ordered' ? `${index}. ` : '- '; - let itemText = ''; - node.forEach((child: any) => { itemText += serializeNode(child); }); - return prefix + itemText.trimEnd() + '\n'; - } - - if (node.isBlock) { - node.forEach((child: any) => { text += serializeNode(child); }); - return text + '\n'; - } - if (!node.isLeaf) { - node.forEach((child: any) => { text += serializeNode(child); }); - } - return text; - }; - - let result = ''; - slice.content.forEach((node: any) => { - result += serializeNode(node); - }); - return result.trim(); - }, - }, - onUpdate: ({ editor: ed }) => { - if (isUpdatingFromStore.current) return; - const currentTabId = useAppStore.getState().activeNoteId; - if (currentTabId) { - useAppStore.getState().updateNoteContent(currentTabId, ed.getHTML()); - } - }, - onSelectionUpdate: () => { - setTick((prev) => prev + 1); - }, - onTransaction: () => { - setTick((prev) => prev + 1); - } - }, []); - - const handleTranscript = useCallback((text: string) => { - if (editor) { - editor.chain().focus().insertContent(text + ' ').run(); - } - }, [editor]); - - const { isListening, toggleListening, isSupported, error } = useSpeechToText({ - onTranscript: handleTranscript, - language - }); - - useEffect(() => { - if (error) { - console.error('Speech-to-Text Error:', error); - } - }, [error]); - - // Sync editor content when active tab changes ONLY - useEffect(() => { - if (!editor || !activeNoteId) return; - const note = useAppStore.getState().notes.find(n => n.id === activeNoteId); - if (note) { - isUpdatingFromStore.current = true; - editor.commands.setContent(note.content || ''); - isUpdatingFromStore.current = false; - } - }, [activeNoteId, editor]); // activeNote dependency removed to prevent cursor jumping! - - // 1. Restore scroll position on mount/tab switch (Non-Reactive) - useEffect(() => { - if (!editor || !activeNoteId) return; - const scrollNode = editor.view.dom.parentElement; - if (!scrollNode) return; - - const savedPos = useAppStore.getState().scrollPositions[`note_${activeNoteId}`]; - if (savedPos !== undefined) { - // 50ms delay allows Tiptap to fully inject the new long content before jumping - setTimeout(() => { scrollNode.scrollTop = savedPos; }, 50); - } - }, [editor, activeNoteId]); - - // 2. Safely track scroll position continuously (Non-Reactive) - useEffect(() => { - if (!editor || !activeNoteId) return; - const scrollNode = editor.view.dom.parentElement; - if (!scrollNode) return; - - const handleScroll = () => { - useAppStore.getState().setScrollPosition(`note_${activeNoteId}`, scrollNode.scrollTop); - }; - - // Passive listener ensures zero scroll performance degradation - scrollNode.addEventListener('scroll', handleScroll, { passive: true }); - return () => scrollNode.removeEventListener('scroll', handleScroll); - }, [editor, activeNoteId]); - - // Handle local image file selection - const handleImageFileSelect = useCallback((e: React.ChangeEvent) => { - if (!editor || !e.target.files || e.target.files.length === 0) return; - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result as string; - editor.chain().focus().setImage({ src: base64 }).run(); - }; - reader.readAsDataURL(file); - // Reset input so the same file can be re-selected - e.target.value = ''; - }, [editor]); - - const triggerImagePicker = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - - - if (!editor || !activeNote) return null; - - return ( -
- {/* Hidden file input for image selection */} - - {/* Title area */} -
- updateNoteTitle(activeNote.id, e.target.value)} - /> -
+ if (!activeNote || activeNote.isSettings) return null; - {/* Formatting Toolbar */} -
- - - -
- - -
- - {isSupported && ( - <> -
- - - )} -
+ if (activeNote.editorType === 'editorjs') { + return ; + } - {/* Tiptap Editor Content */} - -
- ); + // Default: Tiptap (also handles legacy notes without editorType) + return ; }); export default NoteEditor; diff --git a/src/components/notes/SettingsPanel.tsx b/src/components/notes/SettingsPanel.tsx index f6b50df..7372e78 100644 --- a/src/components/notes/SettingsPanel.tsx +++ b/src/components/notes/SettingsPanel.tsx @@ -147,6 +147,8 @@ const SettingsPanel: React.FC = () => { const setIsPopupSmartPositioning = useAppStore(state => state.setIsPopupSmartPositioning); const orientation = useAppStore(state => state.orientation); const setOrientation = useAppStore(state => state.setOrientation); + const preferredNoteEditor = useAppStore(state => state.preferredNoteEditor); + const setPreferredNoteEditor = useAppStore(state => state.setPreferredNoteEditor); // Workspaces const workspaces = useAppStore(state => state.workspaces); @@ -1711,6 +1713,22 @@ const SettingsPanel: React.FC = () => { {/* General Settings Area */}
+
+ +
+ + +
+
+ +
+
power_settings_new diff --git a/src/components/notes/TiptapEditor.tsx b/src/components/notes/TiptapEditor.tsx new file mode 100644 index 0000000..1973e7a --- /dev/null +++ b/src/components/notes/TiptapEditor.tsx @@ -0,0 +1,268 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Underline from '@tiptap/extension-underline'; +import Image from '@tiptap/extension-image'; +import Placeholder from '@tiptap/extension-placeholder'; +import { useAppStore } from '../../store/useAppStore'; +import { useSpeechToText } from '../../hooks/useSpeechToText'; + +const TiptapEditor: React.FC = React.memo(() => { + const { activeNoteId, updateNoteTitle, t, design, language } = useAppStore(); + const activeNote = useAppStore((state) => state.notes.find(n => n.id === activeNoteId)); + const fileInputRef = useRef(null); + const isUpdatingFromStore = useRef(false); + const [, setTick] = useState(0); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + bulletList: { keepMarks: true }, + orderedList: { keepMarks: true }, + }), + Underline, + Image.configure({ + inline: false, + allowBase64: true + }), + Placeholder.configure({ + placeholder: 'Start writing your note…', + }), + ], + content: activeNote?.content || '', + editorProps: { + attributes: { + class: 'flex-1 text-slate-300 text-lg leading-relaxed outline-none no-drag-region overflow-y-auto max-w-none', + spellcheck: 'false' + }, + clipboardTextSerializer: (slice) => { + const serializeNode = (node: any, listType: 'bullet' | 'ordered' | null = null, index: number = 0): string => { + if (node.isText) return node.text || ''; + if (node.type.name === 'hardBreak') return '\n'; + + let text = ''; + if (node.type.name === 'bulletList') { + node.forEach((child: any) => { text += serializeNode(child, 'bullet'); }); + return text; + } + if (node.type.name === 'orderedList') { + let i = node.attrs?.start || 1; + node.forEach((child: any) => { + text += serializeNode(child, 'ordered', i); + i++; + }); + return text; + } + if (node.type.name === 'listItem') { + const prefix = listType === 'ordered' ? `${index}. ` : '- '; + let itemText = ''; + node.forEach((child: any) => { itemText += serializeNode(child); }); + return prefix + itemText.trimEnd() + '\n'; + } + + if (node.isBlock) { + node.forEach((child: any) => { text += serializeNode(child); }); + return text + '\n'; + } + if (!node.isLeaf) { + node.forEach((child: any) => { text += serializeNode(child); }); + } + return text; + }; + + let result = ''; + slice.content.forEach((node: any) => { + result += serializeNode(node); + }); + return result.trim(); + }, + }, + onUpdate: ({ editor: ed }) => { + if (isUpdatingFromStore.current) return; + const currentTabId = useAppStore.getState().activeNoteId; + if (currentTabId) { + useAppStore.getState().updateNoteContent(currentTabId, ed.getHTML()); + } + }, + onSelectionUpdate: () => { + setTick((prev) => prev + 1); + }, + onTransaction: () => { + setTick((prev) => prev + 1); + } + }, []); + + const handleTranscript = useCallback((text: string) => { + if (editor) { + editor.chain().focus().insertContent(text + ' ').run(); + } + }, [editor]); + + const { isListening, toggleListening, isSupported, error } = useSpeechToText({ + onTranscript: handleTranscript, + language + }); + + useEffect(() => { + if (error) { + console.error('Speech-to-Text Error:', error); + } + }, [error]); + + // Sync editor content when active tab changes ONLY + useEffect(() => { + if (!editor || !activeNoteId) return; + const note = useAppStore.getState().notes.find(n => n.id === activeNoteId); + if (note) { + isUpdatingFromStore.current = true; + editor.commands.setContent(note.content || ''); + isUpdatingFromStore.current = false; + } + }, [activeNoteId, editor]); // activeNote dependency removed to prevent cursor jumping! + + // 1. Restore scroll position on mount/tab switch (Non-Reactive) + useEffect(() => { + if (!editor || !activeNoteId) return; + const scrollNode = editor.view.dom.parentElement; + if (!scrollNode) return; + + const savedPos = useAppStore.getState().scrollPositions[`note_${activeNoteId}`]; + if (savedPos !== undefined) { + // 50ms delay allows Tiptap to fully inject the new long content before jumping + setTimeout(() => { scrollNode.scrollTop = savedPos; }, 50); + } + }, [editor, activeNoteId]); + + // 2. Safely track scroll position continuously (Non-Reactive) + useEffect(() => { + if (!editor || !activeNoteId) return; + const scrollNode = editor.view.dom.parentElement; + if (!scrollNode) return; + + const handleScroll = () => { + useAppStore.getState().setScrollPosition(`note_${activeNoteId}`, scrollNode.scrollTop); + }; + + // Passive listener ensures zero scroll performance degradation + scrollNode.addEventListener('scroll', handleScroll, { passive: true }); + return () => scrollNode.removeEventListener('scroll', handleScroll); + }, [editor, activeNoteId]); + + // Handle local image file selection + const handleImageFileSelect = useCallback((e: React.ChangeEvent) => { + if (!editor || !e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result as string; + editor.chain().focus().setImage({ src: base64 }).run(); + }; + reader.readAsDataURL(file); + // Reset input so the same file can be re-selected + e.target.value = ''; + }, [editor]); + + const triggerImagePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + + + if (!editor || !activeNote) return null; + + return ( +
+ {/* Hidden file input for image selection */} + + + {/* Title area */} +
+ updateNoteTitle(activeNote.id, e.target.value)} + /> +
+ + {/* Formatting Toolbar */} +
+ + + +
+ + +
+ + {isSupported && ( + <> +
+ + + )} +
+ + {/* Tiptap Editor Content */} + +
+ ); +}); + +export default TiptapEditor; diff --git a/src/components/notes/editorConverters.ts b/src/components/notes/editorConverters.ts new file mode 100644 index 0000000..4296191 --- /dev/null +++ b/src/components/notes/editorConverters.ts @@ -0,0 +1,226 @@ +/** + * Bidirectional converters between Tiptap HTML and Editor.js OutputData format. + * Used when a user wants to convert a note from one editor engine to the other. + */ + +// ─── Editor.js OutputData Types (inline, no external dep needed) ─── + +export interface EditorJsBlock { + id?: string; + type: string; + data: Record; +} + +export interface EditorJsOutputData { + time?: number; + blocks: EditorJsBlock[]; + version?: string; +} + +// ─── Tiptap HTML → Editor.js OutputData ─── + +export function tiptapHtmlToEditorJs(html: string): EditorJsOutputData { + if (!html || html.trim() === '') { + return { time: Date.now(), blocks: [], version: '2.30.0' }; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const blocks: EditorJsBlock[] = []; + + const children = Array.from(doc.body.childNodes); + + for (const node of children) { + if (node.nodeType === Node.TEXT_NODE) { + const text = (node.textContent || '').trim(); + if (text) { + blocks.push({ type: 'paragraph', data: { text } }); + } + continue; + } + + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as HTMLElement; + const tagName = el.tagName.toLowerCase(); + + // Skip empty
tags used as spacers + if (tagName === 'br') continue; + + // Headings + const headingMatch = tagName.match(/^h([1-6])$/); + if (headingMatch) { + blocks.push({ + type: 'header', + data: { text: el.innerHTML.trim(), level: parseInt(headingMatch[1], 10) } + }); + continue; + } + + // Unordered list + if (tagName === 'ul') { + const items = Array.from(el.querySelectorAll(':scope > li')).map(li => (li as HTMLElement).innerHTML.trim()); + if (items.length > 0) { + blocks.push({ type: 'list', data: { style: 'unordered', items } }); + } + continue; + } + + // Ordered list + if (tagName === 'ol') { + const items = Array.from(el.querySelectorAll(':scope > li')).map(li => (li as HTMLElement).innerHTML.trim()); + if (items.length > 0) { + blocks.push({ type: 'list', data: { style: 'ordered', items } }); + } + continue; + } + + // Blockquote + if (tagName === 'blockquote') { + blocks.push({ type: 'quote', data: { text: el.innerHTML.trim(), caption: '' } }); + continue; + } + + // Code block + if (tagName === 'pre') { + const code = el.querySelector('code'); + blocks.push({ type: 'code', data: { code: (code || el).textContent || '' } }); + continue; + } + + // Horizontal rule + if (tagName === 'hr') { + blocks.push({ type: 'delimiter', data: {} }); + continue; + } + + // Image + if (tagName === 'img') { + blocks.push({ + type: 'image', + data: { + file: { url: el.getAttribute('src') || '' }, + caption: el.getAttribute('alt') || '', + withBorder: false, + stretched: false, + withBackground: false, + } + }); + continue; + } + + // Paragraph (default) — skip empty paragraphs that are just spacers + if (tagName === 'p') { + const inner = el.innerHTML.trim(); + // Check if paragraph contains only an image + const imgChild = el.querySelector('img'); + if (imgChild && el.childNodes.length === 1) { + blocks.push({ + type: 'image', + data: { + file: { url: imgChild.getAttribute('src') || '' }, + caption: imgChild.getAttribute('alt') || '', + withBorder: false, + stretched: false, + withBackground: false, + } + }); + continue; + } + if (inner && inner !== '
' && inner !== '
') { + blocks.push({ type: 'paragraph', data: { text: inner } }); + } + continue; + } + + // Fallback: treat as paragraph with raw HTML + const fallbackText = el.innerHTML.trim(); + if (fallbackText) { + blocks.push({ type: 'paragraph', data: { text: fallbackText } }); + } + } + + return { time: Date.now(), blocks, version: '2.30.0' }; +} + +// ─── Editor.js OutputData → Tiptap HTML ─── + +export function editorJsToTiptapHtml(data: EditorJsOutputData): string { + if (!data || !data.blocks || data.blocks.length === 0) { + return ''; + } + + const parts: string[] = []; + + for (const block of data.blocks) { + switch (block.type) { + case 'paragraph': + parts.push(`

${block.data.text || ''}

`); + break; + + case 'header': { + const level = block.data.level || 2; + parts.push(`${block.data.text || ''}`); + break; + } + + case 'list': { + const tag = block.data.style === 'ordered' ? 'ol' : 'ul'; + const items = (block.data.items as string[]) || []; + const lis = items.map(item => `
  • ${item}
  • `).join(''); + parts.push(`<${tag}>${lis}`); + break; + } + + case 'checklist': { + const checkItems = (block.data.items as Array<{ text: string; checked: boolean }>) || []; + const lis = checkItems.map(item => + `
  • ${item.checked ? '☑ ' : '☐ '}${item.text}
  • ` + ).join(''); + parts.push(`
      ${lis}
    `); + break; + } + + case 'quote': + parts.push(`
    ${block.data.text || ''}
    `); + break; + + case 'code': + parts.push(`
    ${block.data.code || ''}
    `); + break; + + case 'delimiter': + parts.push('
    '); + break; + + case 'image': { + const fileData = block.data.file as { url?: string } | undefined; + const url = fileData?.url || ''; + const caption = (block.data.caption as string) || ''; + parts.push(`${caption}`); + break; + } + + default: + // Unknown block types: render as paragraph + if (block.data.text) { + parts.push(`

    ${block.data.text}

    `); + } + break; + } + } + + return parts.join('\n'); +} + +// ─── Validator ─── + +export function validateEditorJsData(data: unknown): data is EditorJsOutputData { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + if (!Array.isArray(obj.blocks)) return false; + return obj.blocks.every((block: unknown) => { + if (!block || typeof block !== 'object') return false; + const b = block as Record; + return typeof b.type === 'string' && typeof b.data === 'object' && b.data !== null; + }); +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 6a08147..66647db 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -148,6 +148,18 @@ export const translations = { todoListHeader: "To-Do List", addTaskTitle: "Add Task", todoEmptyState: "All caught up! Add a task to start.", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "New task...", snippetVaultHeader: "Snippet Vault", searchPlaceholder: "Search snippets, tags...", @@ -395,6 +407,18 @@ export const translations = { todoListHeader: "Yapılacaklar Listesi", addTaskTitle: "Görev Ekle", todoEmptyState: "Hepsi tamam! Başlamak için bir görev ekleyin.", + noteEditorEngine: "Not Editör Motoru", + editorTiptap: "Tiptap (Zengin Metin)", + editorEditorJs: "Editor.js (Blok)", + editorjsPlaceholder: "Blok eklemek için '/' tuşuna veya '+' simgesine tıklayın...", + blockHeading: "Başlık", + blockBulletedList: "Madde İşaretli Liste", + blockNumberedList: "Numaralı Liste", + blockChecklist: "Kontrol Listesi", + blockCode: "Kod", + blockQuote: "Alıntı", + blockImage: "Görsel", + blockDivider: "Ayırıcı", todoPlaceholder: "Yeni görev...", snippetVaultHeader: "Snippet Arşivi", searchPlaceholder: "Snippet, etiket ara...", @@ -642,6 +666,18 @@ export const translations = { todoListHeader: "To-Do-Liste", addTaskTitle: "Aufgabe hinzufügen", todoEmptyState: "Alles erledigt!", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Neue Aufgabe...", snippetVaultHeader: "Snippet-Tresor", searchPlaceholder: "Suchen...", @@ -889,6 +925,18 @@ export const translations = { todoListHeader: "Liste de tâches", addTaskTitle: "Ajouter une tâche", todoEmptyState: "Tout est fait !", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Nouvelle tâche...", snippetVaultHeader: "Coffre à extraits", searchPlaceholder: "Rechercher...", @@ -1136,6 +1184,18 @@ export const translations = { todoListHeader: "Lista de tareas", addTaskTitle: "Añadir tarea", todoEmptyState: "¡Todo listo!", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Nueva tarea...", snippetVaultHeader: "Bóveda de fragmentos", searchPlaceholder: "Buscar...", @@ -1383,6 +1443,18 @@ export const translations = { todoListHeader: "Список дел", addTaskTitle: "Добавить задачу", todoEmptyState: "Все выполнено!", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Новая задача...", snippetVaultHeader: "Хранилище сниппетов", searchPlaceholder: "Поиск...", @@ -1630,6 +1702,18 @@ export const translations = { todoListHeader: "قائمة المهام", addTaskTitle: "إضافة مهمة", todoEmptyState: "اكتملت جميع المهام!", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "مهمة جديدة...", snippetVaultHeader: "خزنة المقتطفات", searchPlaceholder: "بحث...", @@ -1877,6 +1961,18 @@ export const translations = { todoListHeader: "待办列表", addTaskTitle: "添加任务", todoEmptyState: "任务已全部完成!", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "新任务...", snippetVaultHeader: "代码段库", searchPlaceholder: "搜索...", @@ -2111,6 +2207,18 @@ export const translations = { todoListHeader: "TODOリスト", addTaskTitle: "タスクを追加", todoEmptyState: "全て完了!タスクを追加してください。", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "新しいタスク...", snippetVaultHeader: "スニペット保管庫", searchPlaceholder: "検索...", @@ -2358,6 +2466,18 @@ export const translations = { todoListHeader: "कार्य सूची", addTaskTitle: "कार्य जोड़ें", todoEmptyState: "सब हो गया!", + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "नया कार्य...", snippetVaultHeader: "स्निपेट वॉल्ट", searchPlaceholder: "खोजें...", diff --git a/src/index.css b/src/index.css index 817f7a3..10424c7 100644 --- a/src/index.css +++ b/src/index.css @@ -329,6 +329,69 @@ body { border-radius: 0.5rem; margin: 1rem 0; } + +/* Editor.js Dark Theme Overrides */ +.ce-block__content, +.ce-toolbar__content { + max-width: 100%; +} + +.ce-inline-toolbar, +.ce-conversion-toolbar, +.ce-settings, +.ce-popover { + background-color: var(--theme-bg-base) !important; + border: 1px solid var(--theme-border) !important; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5) !important; + color: #cbd5e1 !important; +} + +.ce-inline-toolbar__buttons, +.ce-conversion-toolbar__buttons { + color: #cbd5e1; +} + +.ce-inline-tool, +.ce-conversion-tool, +.ce-settings__button, +.ce-popover-item { + color: #cbd5e1 !important; +} + +.ce-inline-tool:hover, +.ce-conversion-tool:hover, +.ce-settings__button:hover, +.ce-popover-item:hover { + background-color: rgba(255,255,255,0.05) !important; + color: var(--theme-primary) !important; +} + +.ce-inline-tool--active, +.ce-popover-item--active { + color: var(--theme-primary) !important; +} + +.tc-toolbox, .tc-popover { + background-color: var(--theme-bg-base) !important; + border: 1px solid var(--theme-border) !important; + color: #cbd5e1 !important; +} + +.tc-popover__item:hover { + background-color: rgba(255,255,255,0.05) !important; +} + +.codex-editor { + color: #cbd5e1; + font-size: 1.125rem; + line-height: 1.75; +} + +.codex-editor * { + user-select: text !important; + -webkit-user-select: text !important; +} + /* Prevent accidental text/element selection globally */ * { user-select: none; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index c0d8158..cab721e 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -62,6 +62,8 @@ export interface Note { icon: string; emoji: string | null; content: string; + editorType: 'tiptap' | 'editorjs'; + editorjsData?: string; isSettings?: boolean; } @@ -175,9 +177,13 @@ interface AppState { addNote: () => void; deleteNote: (id: number) => void; updateNoteContent: (id: number, content: string) => void; + updateNoteEditorjsData: (id: number, data: string) => void; updateNoteTitle: (id: number, title: string) => void; updateNoteEmoji: (id: number, emoji: string) => void; openSettingsTab: () => void; + // Note Editor Engine Preference + preferredNoteEditor: 'tiptap' | 'editorjs'; + setPreferredNoteEditor: (editor: 'tiptap' | 'editorjs') => void; // App Launcher pinnedApps: PinnedApp[]; pinApp: (app: PinnedApp) => void; @@ -420,6 +426,7 @@ const defaultNotes: Note[] = [ title: 'Welcome to KoBar!', icon: 'waving_hand', emoji: '👋', + editorType: 'tiptap', content: `

    Your modular, always-on-top desktop utility sidebar.

    A multi-threaded creative assistant that lives on the edge of your screen.

    @@ -852,6 +859,9 @@ export const useAppStore = create()( activeNoteId: 1, nextNoteId: 2, setActiveNoteId: (id) => set({ activeNoteId: id }), + // Note Editor Engine Preference + preferredNoteEditor: 'tiptap', + setPreferredNoteEditor: (editor) => set({ preferredNoteEditor: editor }), addNote: () => set((state) => { const newNote: Note = { id: state.nextNoteId, @@ -859,6 +869,7 @@ export const useAppStore = create()( icon: 'note', emoji: null, content: '', + editorType: state.preferredNoteEditor, }; return { notes: [...state.notes, newNote], @@ -877,6 +888,9 @@ export const useAppStore = create()( updateNoteContent: (id, content) => set((state) => ({ notes: state.notes.map(n => n.id === id ? { ...n, content } : n), })), + updateNoteEditorjsData: (id, data) => set((state) => ({ + notes: state.notes.map(n => n.id === id ? { ...n, editorjsData: data } : n), + })), updateNoteTitle: (id, title) => set((state) => ({ notes: state.notes.map(n => n.id === id ? { ...n, title } : n), })), @@ -895,6 +909,7 @@ export const useAppStore = create()( icon: 'settings', emoji: null, content: '', + editorType: 'tiptap', isSettings: true, }; nextNotes = [...state.notes, settingsNote]; @@ -1080,7 +1095,7 @@ export const useAppStore = create()( }), { name: 'kobar-storage', - version: 18, + version: 19, migrate: (persistedState: any, version: number) => { // version 18 migration for orientation if (version <= 17) { @@ -1252,6 +1267,20 @@ export const useAppStore = create()( } } + // version 19 migration for Editor.js support + if (version <= 18) { + // Add editorType to all existing notes that lack it + if (persistedState.notes) { + persistedState.notes = persistedState.notes.map((n: any) => ({ + ...n, + editorType: n.editorType || 'tiptap', + })); + } + if (persistedState.preferredNoteEditor === undefined) { + persistedState.preferredNoteEditor = 'tiptap'; + } + } + return persistedState; }, partialize: (state) => ({ @@ -1305,6 +1334,7 @@ export const useAppStore = create()( settingsWorkspaceViewMode: state.settingsWorkspaceViewMode, orientation: state.orientation, edgePosition: state.edgePosition, + preferredNoteEditor: state.preferredNoteEditor, }), onRehydrateStorage: (_) => { console.log('[Store] Hydration starting...'); diff --git a/update_translations.cjs b/update_translations.cjs new file mode 100644 index 0000000..a060a10 --- /dev/null +++ b/update_translations.cjs @@ -0,0 +1,44 @@ +const fs = require('fs'); +const path = require('path'); + +const filePath = path.join(__dirname, 'src', 'i18n', 'translations.ts'); +let content = fs.readFileSync(filePath, 'utf8'); + +const keysEn = ` + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider"`; + +const keysTr = ` + noteEditorEngine: "Not Editör Motoru", + editorTiptap: "Tiptap (Zengin Metin)", + editorEditorJs: "Editor.js (Blok)", + editorjsPlaceholder: "Blok eklemek için '/' tuşuna veya '+' simgesine tıklayın...", + blockHeading: "Başlık", + blockBulletedList: "Madde İşaretli Liste", + blockNumberedList: "Numaralı Liste", + blockChecklist: "Kontrol Listesi", + blockCode: "Kod", + blockQuote: "Alıntı", + blockImage: "Görsel", + blockDivider: "Ayırıcı"`; + +content = content.replace(/(todoEmptyState:\s*".*?")/g, (match) => { + if (match.includes("Hepsi tamam")) { + return match + "," + keysTr; + } else { + return match + "," + keysEn; + } +}); + +fs.writeFileSync(filePath, content, 'utf8'); +console.log("Translations fixed successfully!"); From 2686f7ffe36fc5988c9a2861452da25ce286adc8 Mon Sep 17 00:00:00 2001 From: Ekrem EDALI <83559434+eedali@users.noreply.github.com> Date: Thu, 28 May 2026 21:20:56 +0300 Subject: [PATCH 02/11] feat: implement themed Tailwind CSS integration and Editor.js text editor components --- src/components/notes/CustomCodeTool.ts | 271 ++++++++++++++++++++++++ src/components/notes/EditorJsEditor.tsx | 6 +- src/index.css | 42 ++++ src/types/global.d.ts | 2 + 4 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 src/components/notes/CustomCodeTool.ts diff --git a/src/components/notes/CustomCodeTool.ts b/src/components/notes/CustomCodeTool.ts new file mode 100644 index 0000000..5f283eb --- /dev/null +++ b/src/components/notes/CustomCodeTool.ts @@ -0,0 +1,271 @@ +import type { API, BlockTool, BlockToolData, ToolConfig } from '@editorjs/editorjs'; + +export interface CodeData extends BlockToolData { + code: string; + language: string; + height: number; +} + +export default class CustomCodeTool implements BlockTool { + api: API; + data: CodeData; + config: ToolConfig; + wrapper: HTMLElement | null = null; + textarea: HTMLTextAreaElement | null = null; + pre: HTMLElement | null = null; + codeContainer: HTMLElement | null = null; + select: HTMLSelectElement | null = null; + + static get isReadOnlySupported() { + return true; + } + + static get enableLineBreaks() { + return true; + } + + static get toolbox() { + return { + icon: '', + title: 'Code' + }; + } + + constructor({ data, config, api }: { data: any; config: ToolConfig; api: API }) { + this.api = api; + this.config = config; + this.data = { + code: data.code || '', + language: data.language || 'javascript', + height: data.height || 150 + }; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.classList.add('custom-code-tool'); + this.wrapper.style.marginBottom = '1.5rem'; + + const header = document.createElement('div'); + header.classList.add('code-header'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + header.style.backgroundColor = 'var(--theme-bg-dark)'; + header.style.padding = '4px 12px'; + header.style.borderTopLeftRadius = '8px'; + header.style.borderTopRightRadius = '8px'; + header.style.border = '1px solid var(--theme-border)'; + header.style.borderBottom = 'none'; + header.style.userSelect = 'none'; + + this.select = document.createElement('select'); + this.select.style.background = 'transparent'; + this.select.style.color = '#94a3b8'; + this.select.style.border = 'none'; + this.select.style.outline = 'none'; + this.select.style.fontSize = '0.75rem'; + this.select.style.cursor = 'pointer'; + + const langs = ['javascript', 'typescript', 'python', 'html', 'css', 'json', 'bash', 'plaintext']; + langs.forEach(lang => { + const opt = document.createElement('option'); + opt.value = lang; + opt.innerText = lang.toUpperCase(); + opt.style.background = 'var(--theme-bg-base)'; + if (this.data.language === lang) opt.selected = true; + this.select?.appendChild(opt); + }); + + this.select.addEventListener('change', (e) => { + this.data.language = (e.target as HTMLSelectElement).value; + this.highlightCode(); + }); + + const copyBtn = document.createElement('button'); + copyBtn.innerHTML = 'content_copy'; + copyBtn.style.background = 'none'; + copyBtn.style.border = 'none'; + copyBtn.style.color = '#94a3b8'; + copyBtn.style.cursor = 'pointer'; + copyBtn.title = 'Copy code'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(this.textarea?.value || ''); + copyBtn.style.color = 'var(--theme-primary)'; + setTimeout(() => copyBtn.style.color = '#94a3b8', 1000); + }); + + header.appendChild(this.select); + header.appendChild(copyBtn); + + const container = document.createElement('div'); + container.style.position = 'relative'; + container.style.border = '1px solid var(--theme-border)'; + container.style.borderBottomLeftRadius = '8px'; + container.style.borderBottomRightRadius = '8px'; + container.style.overflow = 'hidden'; + container.style.backgroundColor = 'var(--theme-bg-base)'; + + this.textarea = document.createElement('textarea'); + this.textarea.value = this.data.code; + this.textarea.placeholder = 'Write your code here...'; + this.textarea.spellcheck = false; + + // Exact styling to overlap nicely + this.textarea.style.position = 'absolute'; + this.textarea.style.top = '0'; + this.textarea.style.left = '0'; + this.textarea.style.width = '100%'; + this.textarea.style.height = '100%'; + this.textarea.style.margin = '0'; + this.textarea.style.padding = '12px'; + this.textarea.style.border = 'none'; + this.textarea.style.background = 'transparent'; + this.textarea.style.color = 'transparent'; + this.textarea.style.caretColor = '#e2e8f0'; + this.textarea.style.outline = 'none'; + this.textarea.style.resize = 'none'; + this.textarea.style.fontFamily = 'monospace'; + this.textarea.style.fontSize = '14px'; + this.textarea.style.lineHeight = '1.5'; + this.textarea.style.zIndex = '2'; + + this.pre = document.createElement('pre'); + this.pre.style.margin = '0'; + this.pre.style.padding = '12px'; + this.pre.style.pointerEvents = 'none'; + this.pre.style.position = 'absolute'; + this.pre.style.top = '0'; + this.pre.style.left = '0'; + this.pre.style.width = '100%'; + this.pre.style.height = '100%'; + this.pre.style.zIndex = '1'; + this.pre.style.fontFamily = 'monospace'; + this.pre.style.fontSize = '14px'; + this.pre.style.lineHeight = '1.5'; + this.pre.style.whiteSpace = 'pre-wrap'; + this.pre.style.wordBreak = 'break-all'; + this.pre.style.color = '#e2e8f0'; + this.pre.style.overflow = 'hidden'; + + this.codeContainer = document.createElement('code'); + this.pre.appendChild(this.codeContainer); + + const resizer = document.createElement('div'); + resizer.style.position = 'absolute'; + resizer.style.bottom = '0'; + resizer.style.left = '0'; + resizer.style.width = '100%'; + resizer.style.height = '12px'; + resizer.style.background = 'transparent'; + resizer.style.cursor = 'ns-resize'; + resizer.style.zIndex = '3'; + + // A small dash line for visuals + const dash = document.createElement('div'); + dash.style.width = '30px'; + dash.style.height = '2px'; + dash.style.background = 'var(--theme-border)'; + dash.style.margin = '5px auto'; + resizer.appendChild(dash); + + container.style.height = this.data.height + 'px'; + + let isResizing = false; + let startY = 0; + let startHeight = 0; + + resizer.addEventListener('mousedown', (e) => { + isResizing = true; + startY = e.clientY; + startHeight = container.getBoundingClientRect().height; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + const newHeight = Math.max(80, startHeight + (e.clientY - startY)); + container.style.height = newHeight + 'px'; + this.data.height = newHeight; + }); + + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + // Dispatch change event to save + this.api.saver.save(); + } + }); + + this.textarea.addEventListener('input', () => { + this.highlightCode(); + }); + + this.textarea.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = this.textarea!.selectionStart; + const end = this.textarea!.selectionEnd; + this.textarea!.value = this.textarea!.value.substring(0, start) + " " + this.textarea!.value.substring(end); + this.textarea!.selectionStart = this.textarea!.selectionEnd = start + 4; + this.highlightCode(); + } + }); + + this.textarea.addEventListener('scroll', () => { + if (this.pre && this.textarea) { + this.pre.scrollTop = this.textarea.scrollTop; + } + }); + + container.appendChild(this.pre); + container.appendChild(this.textarea); + container.appendChild(resizer); + + this.wrapper.appendChild(header); + this.wrapper.appendChild(container); + + this.highlightCode(); + + return this.wrapper; + } + + highlightCode() { + if (!this.textarea || !this.codeContainer) return; + const code = this.textarea.value; + this.data.code = code; + + // Escape HTML + let html = code.replace(/&/g, '&').replace(//g, '>'); + + const lang = this.data.language; + if (lang === 'javascript' || lang === 'typescript' || lang === 'json') { + html = html.replace(/\b(const|let|var|function|return|if|else|for|while|class|import|export|from|new|true|false|null|undefined|await|async|interface|type)\b/g, '$1'); + html = html.replace(/(["'`].*?["'`])/g, '$1'); + html = html.replace(/\b(\d+)\b/g, '$1'); + html = html.replace(/(\/\/.*)/g, '$1'); + } else if (lang === 'html') { + html = html.replace(/(<\/?)([a-zA-Z0-9]+)/g, '$1$2'); + html = html.replace(/([a-zA-Z0-9-]+)(=)/g, '$1$2'); + html = html.replace(/(["'].*?["'])/g, '$1'); + } else if (lang === 'css') { + html = html.replace(/([a-zA-Z0-9-]+)(\s*:)/g, '$1$2'); + html = html.replace(/(#[0-9a-fA-F]+|\b\d+(px|em|rem|%)\b)/g, '$1'); + } else if (lang === 'python') { + html = html.replace(/\b(def|return|if|elif|else|for|while|class|import|from|True|False|None|await|async|in|and|or|not)\b/g, '$1'); + html = html.replace(/(["'`].*?["'`])/g, '$1'); + html = html.replace(/\b(\d+)\b/g, '$1'); + html = html.replace(/(#.*)/g, '$1'); + } + + this.codeContainer.innerHTML = html + (html.endsWith('\n') ? '
    ' : ''); + } + + save() { + return { + code: this.data.code, + language: this.data.language, + height: this.data.height + }; + } +} diff --git a/src/components/notes/EditorJsEditor.tsx b/src/components/notes/EditorJsEditor.tsx index e470581..0fd657b 100644 --- a/src/components/notes/EditorJsEditor.tsx +++ b/src/components/notes/EditorJsEditor.tsx @@ -4,7 +4,7 @@ import EditorJS from '@editorjs/editorjs'; import Header from '@editorjs/header'; import List from '@editorjs/list'; import Checklist from '@editorjs/checklist'; -import CodeTool from '@editorjs/code'; +import CustomCodeTool from './CustomCodeTool'; import Quote from '@editorjs/quote'; import Delimiter from '@editorjs/delimiter'; import { useAppStore } from '../../store/useAppStore'; @@ -132,7 +132,7 @@ const EditorJsEditor: React.FC = React.memo(() => { inlineToolbar: true, }, code: { - class: CodeTool as any, + class: CustomCodeTool as any, }, quote: { class: Quote as any, @@ -298,7 +298,7 @@ const EditorJsEditor: React.FC = React.memo(() => { allBlocks.forEach((b, i) => { if (b === blockEl) blockIndex = i; }); setPlusTrigger({ - x: rect.left - 36, + x: rect.left + 4, // Shifted to right to avoid overlap with native drag handle y: rect.top + rect.height / 2 - 12, blockIndex, }); diff --git a/src/index.css b/src/index.css index 10424c7..0d7eb05 100644 --- a/src/index.css +++ b/src/index.css @@ -149,9 +149,11 @@ @layer utilities { .drag-region { + -webkit-app-region: drag; } .no-drag-region { + -webkit-app-region: no-drag; } .custom-radio { @@ -336,6 +338,11 @@ body { max-width: 100%; } +/* Adjust block spacing to prevent overlapping with plus button */ +.ce-block__content { + padding-left: 36px !important; +} + .ce-inline-toolbar, .ce-conversion-toolbar, .ce-settings, @@ -344,6 +351,8 @@ body { border: 1px solid var(--theme-border) !important; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5) !important; color: #cbd5e1 !important; + -webkit-app-region: no-drag !important; + pointer-events: auto !important; } .ce-inline-toolbar__buttons, @@ -375,6 +384,8 @@ body { background-color: var(--theme-bg-base) !important; border: 1px solid var(--theme-border) !important; color: #cbd5e1 !important; + -webkit-app-region: no-drag !important; + pointer-events: auto !important; } .tc-popover__item:hover { @@ -392,6 +403,37 @@ body { -webkit-user-select: text !important; } +/* Quote Redesign */ +.cdx-quote { + border-left: 4px solid var(--theme-primary) !important; + background-color: rgba(255, 255, 255, 0.03) !important; + padding: 1rem 1.5rem !important; + border-radius: 0 0.75rem 0.75rem 0 !important; + margin: 1.5rem 0 !important; + position: relative; + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.1); +} +.cdx-quote::before { + content: '"'; + position: absolute; + left: 10px; + top: -10px; + font-size: 3rem; + color: var(--theme-primary); + opacity: 0.2; + font-family: serif; + pointer-events: none; +} +.cdx-quote__text { + font-style: italic; + color: #e2e8f0; + margin-bottom: 0.5rem !important; +} +.cdx-quote__caption { + font-size: 0.875rem; + color: #94a3b8; +} + /* Prevent accidental text/element selection globally */ * { user-select: none; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 5dc890a..6166199 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,5 +1,7 @@ export { }; +declare module '@editorjs/checklist'; + declare global { interface MediaData { title: string; From e4475632740d9dd1eb863f14d6f8abf1a391e8ef Mon Sep 17 00:00:00 2001 From: Ekrem EDALI <83559434+eedali@users.noreply.github.com> Date: Thu, 28 May 2026 21:40:01 +0300 Subject: [PATCH 03/11] feat: add tailwind configuration and global theme styles including editor overrides --- src/index.css | 78 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/src/index.css b/src/index.css index 0d7eb05..2463383 100644 --- a/src/index.css +++ b/src/index.css @@ -343,18 +343,82 @@ body { padding-left: 36px !important; } +/* Base Popovers and Toolbars */ .ce-inline-toolbar, .ce-conversion-toolbar, .ce-settings, -.ce-popover { +.ce-popover, +.ce-popover__container { background-color: var(--theme-bg-base) !important; border: 1px solid var(--theme-border) !important; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important; color: #cbd5e1 !important; -webkit-app-region: no-drag !important; pointer-events: auto !important; } +/* Popover Search/Filter */ +.cdx-search-field { + background-color: var(--theme-bg-dark) !important; + border-bottom: 1px solid var(--theme-border) !important; + color: #cbd5e1 !important; +} +.cdx-search-field__input { + color: #e2e8f0 !important; +} +.cdx-search-field__icon svg { + fill: #94a3b8 !important; +} + +/* Popover Items */ +.ce-popover-item, +.ce-popover__item { + color: #cbd5e1 !important; +} +.ce-popover-item:hover, +.ce-popover__item:hover { + background-color: rgba(255,255,255,0.05) !important; + color: var(--theme-primary) !important; +} +.ce-popover-item--active, +.ce-popover__item--active { + color: var(--theme-primary) !important; + background-color: rgba(255,255,255,0.03) !important; +} +.ce-popover-item__icon, +.ce-popover__item-icon { + color: inherit !important; + background-color: transparent !important; + box-shadow: none !important; + border: none !important; +} +.ce-popover-item:hover .ce-popover-item__icon, +.ce-popover__item:hover .ce-popover__item-icon { + background-color: transparent !important; +} + +/* Toolbar Buttons (Drag Handle, Plus) */ +.ce-toolbar__settings-btn, +.ce-toolbar__plus { + color: #94a3b8 !important; + background-color: transparent !important; +} +.ce-toolbar__settings-btn:hover, +.ce-toolbar__plus:hover { + color: var(--theme-primary) !important; + background-color: var(--theme-bg-base) !important; +} + +/* Block Selection (prevent white left border) */ +.ce-block--selected .ce-block__content { + background-color: rgba(255, 255, 255, 0.03) !important; + border-radius: 8px !important; +} +.ce-block--selected .cdx-block { + background-color: transparent !important; +} + +/* Inline Tools */ .ce-inline-toolbar__buttons, .ce-conversion-toolbar__buttons { color: #cbd5e1; @@ -362,24 +426,22 @@ body { .ce-inline-tool, .ce-conversion-tool, -.ce-settings__button, -.ce-popover-item { +.ce-settings__button { color: #cbd5e1 !important; } .ce-inline-tool:hover, .ce-conversion-tool:hover, -.ce-settings__button:hover, -.ce-popover-item:hover { +.ce-settings__button:hover { background-color: rgba(255,255,255,0.05) !important; color: var(--theme-primary) !important; } -.ce-inline-tool--active, -.ce-popover-item--active { +.ce-inline-tool--active { color: var(--theme-primary) !important; } +/* Table Tool (if used later) */ .tc-toolbox, .tc-popover { background-color: var(--theme-bg-base) !important; border: 1px solid var(--theme-border) !important; From 064c6af0c46f48cd2043fa326cc076b6751e6bc6 Mon Sep 17 00:00:00 2001 From: Ekrem EDALI <83559434+eedali@users.noreply.github.com> Date: Thu, 28 May 2026 21:44:05 +0300 Subject: [PATCH 04/11] feat: add BlockInsertionPanel component and integrate into EditorJsEditor for block selection --- src/components/notes/BlockInsertionPanel.tsx | 3 +-- src/components/notes/EditorJsEditor.tsx | 3 ++- src/index.css | 11 +++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/notes/BlockInsertionPanel.tsx b/src/components/notes/BlockInsertionPanel.tsx index fba16a9..2a3aa13 100644 --- a/src/components/notes/BlockInsertionPanel.tsx +++ b/src/components/notes/BlockInsertionPanel.tsx @@ -102,7 +102,6 @@ const BlockInsertionPanel: React.FC = ({ } const panelRect = panelRef.current.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); const notePanel = containerRef.current?.closest('.relative') as HTMLElement | null; const notePanelRect = notePanel?.getBoundingClientRect(); @@ -140,7 +139,7 @@ const BlockInsertionPanel: React.FC = ({ const panelContent = (
    { {plusTrigger && !showInsertPanel && createPortal(