diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 92be0f9..c04bb61 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -160,6 +160,10 @@ let slashSelectedIndex = $state(0); let slashTablePicker = $state(false); let slashTableHover = $state({ rows: 0, cols: 0 }); + let slashColorPicker = $state(false); + let slashColorHex = $state('#4b6abf'); + let slashColorInputEl = $state(null); + const colorPresets = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#64748b', '#000000', '#ffffff']; interface SlashCommand { label: string; @@ -197,6 +201,7 @@ { label: 'Date', aliases: ['date', 'today', 'day'], icon: '', action: () => insertTimestamp('date') }, { label: 'Time', aliases: ['time', 'clock'], icon: '', action: () => insertTimestamp('time') }, { label: 'Date & Time', aliases: ['datetime', 'now', 'timestamp', 'stamp'], icon: '', action: () => insertTimestamp('datetime') }, + { label: 'Color', aliases: ['color', 'colour', 'hex', 'rgb', 'swatch', 'palette'], icon: '', action: () => { slashColorPicker = true; } }, ]; } @@ -924,6 +929,60 @@ }, }); + // Color swatch decorations: render a small filled square before every hex/rgb/hsl color + // literal (in normal text AND code blocks), VSCode-style. These are view-only widget + // decorations, so they never touch the document/markdown - the note stores only the plain + // color text and the swatch is re-derived on load. + const colorSwatchPluginKey = new PluginKey('colorSwatch'); + const COLOR_LITERAL_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b|(?:rgb|rgba|hsl|hsla)\([^)\n]{1,64}\)/g; + + function makeColorSwatch(color: string): HTMLElement { + const span = document.createElement('span'); + span.className = 'color-swatch'; + span.contentEditable = 'false'; + span.style.backgroundColor = color; + return span; + } + + function buildColorSwatchDecorations(doc: any): DecorationSet { + const decos: any[] = []; + doc.descendants((node: any, pos: number) => { + if (!node.isText || !node.text) return; + const text: string = node.text; + COLOR_LITERAL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = COLOR_LITERAL_RE.exec(text)) !== null) { + const color = m[0]; + // Validate with the browser's own CSS parser so junk (rgb(foo), #12345) gets no swatch. + if (!CSS.supports('color', color)) continue; + const at = pos + m.index; + decos.push(Decoration.widget(at, () => makeColorSwatch(color), { side: -1, key: 'cs:' + color + '@' + at })); + } + }); + return DecorationSet.create(doc, decos); + } + + const ColorSwatch = Extension.create({ + name: 'colorSwatch', + addProseMirrorPlugins() { + return [ + new Plugin({ + key: colorSwatchPluginKey, + state: { + init: (_, state) => buildColorSwatchDecorations(state.doc), + apply: (tr, old, _oldState, newState) => { + if (!tr.docChanged) return old; + return buildColorSwatchDecorations(newState.doc); + }, + }, + props: { + decorations(state) { return colorSwatchPluginKey.getState(state); }, + }, + }), + ]; + }, + }); + const MoveLineShortcuts = Extension.create({ name: 'moveLineShortcuts', addProseMirrorPlugins() { @@ -1084,6 +1143,14 @@ editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run(); return; } + // Color opens a sub-picker too + if (cmd.label === 'Color') { + slashColorPicker = true; + slashSelectedIndex = 0; + editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run(); + tick().then(() => slashColorInputEl?.focus()); + return; + } // Delete the slash trigger text (/ + query) editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run(); slashMenu = null; @@ -1098,6 +1165,14 @@ closeSlashMenu(); } + function insertColor(color: string) { + if (!editor) return; + const c = (color || '').trim(); + if (!c || !CSS.supports('color', c)) { closeSlashMenu(); return; } + editor.chain().focus().insertContent(c).run(); + closeSlashMenu(); + } + // Track whether the user just typed a slash (vs cursor moving into existing text) let slashTypedByUser = false; @@ -1106,6 +1181,7 @@ slashSelectedIndex = 0; slashTablePicker = false; slashTableHover = { rows: 0, cols: 0 }; + slashColorPicker = false; } $effect(() => { @@ -1128,7 +1204,7 @@ const wasSlashTyped = slashTypedByUser; slashTypedByUser = false; if (!editor) return; - if (slashTablePicker) return; // Table picker is open, don't interfere + if (slashTablePicker || slashColorPicker) return; // a sub-picker is open, don't interfere const { state } = editor; const { selection } = state; const resolvedFrom = selection.$from; @@ -1191,6 +1267,13 @@ }, handleKeyDown: (_view, event) => { if (!slashMenu) return false; + if (slashColorPicker) { + if (event.key === 'Escape') { + event.preventDefault(); + closeSlashMenu(); + } + return true; // the picker's own inputs handle the rest + } if (slashTablePicker) { if (event.key === 'Escape') { event.preventDefault(); @@ -2925,6 +3008,7 @@ MoveLineShortcuts, TabIndent, NoteSearchExtension, + ColorSwatch, ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ], content: html, @@ -5442,6 +5526,27 @@ {slashTableHover.rows > 0 ? `${slashTableHover.rows} x ${slashTableHover.cols}` : 'Select table size'} + {:else if slashColorPicker} +
+
+ {#each colorPresets as c} + + {/each} +
+
+ { slashColorHex = (e.target as HTMLInputElement).value; }} title="Pick a color" /> + { slashColorHex = (e.target as HTMLInputElement).value; }} + onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); insertColor(slashColorHex); } else if (e.key === 'Escape') { e.preventDefault(); closeSlashMenu(); } }} + /> + +
+
{:else if slashFiltered.length === 0}
No matching commands
{:else} @@ -8070,6 +8175,81 @@ font-weight: 500; } + /* Color swatch preview shown before a color literal (VSCode-style decorator) */ + :global(.tiptap-wrapper .tiptap .color-swatch) { + display: inline-block; + width: 0.8em; + height: 0.8em; + border-radius: 3px; + margin-right: 0.3em; + vertical-align: baseline; + border: 1px solid var(--border-color); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); + user-select: none; + } + + /* /color slash sub-picker */ + .slash-color-picker { + padding: 8px; + width: 210px; + } + .slash-color-swatches { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 6px; + margin-bottom: 8px; + } + .slash-color-swatch { + width: 100%; + aspect-ratio: 1; + border-radius: 5px; + border: 1px solid var(--border-color); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); + cursor: pointer; + padding: 0; + } + .slash-color-swatch:hover { + transform: scale(1.1); + } + .slash-color-row { + display: flex; + align-items: center; + gap: 6px; + } + .slash-color-native { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + background: none; + cursor: pointer; + flex-shrink: 0; + } + .slash-color-input { + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 12px; + font-family: monospace; + outline: none; + } + .slash-color-insert { + padding: 6px 10px; + border: none; + border-radius: 6px; + background: var(--accent); + color: #fff; + font-size: 12px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + } + /* AI Menu */ .ai-menu-overlay { position: fixed;