mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 20:03:21 +02:00
Blocks (#439)
Added blocks to notes and updated assistant skill with this. Image blocks — images with alt text and captions Embed blocks — inline YouTube videos, Figma designs, or link cards Chart blocks — line, bar, and pie charts from inline data or JSON files Table blocks — styled data tables with named columns
This commit is contained in:
parent
a10e97110d
commit
91030a5fca
11 changed files with 1016 additions and 0 deletions
|
|
@ -46,6 +46,7 @@
|
|||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.6.10",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import TaskList from '@tiptap/extension-task-list'
|
|||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -136,6 +140,14 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
blocks.push(listLines.join('\n'))
|
||||
} else if (node.type === 'taskBlock') {
|
||||
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'imageBlock') {
|
||||
blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'embedBlock') {
|
||||
blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'chartBlock') {
|
||||
blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'tableBlock') {
|
||||
blocks.push('```table\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||
} else if (node.type === 'codeBlock') {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||
|
|
@ -429,6 +441,10 @@ export function MarkdownEditor({
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
|
|||
173
apps/x/apps/renderer/src/extensions/chart-block.tsx
Normal file
173
apps/x/apps/renderer/src/extensions/chart-block.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, BarChart3 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts'
|
||||
|
||||
const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f']
|
||||
|
||||
function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.ChartBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.ChartBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
const [fileData, setFileData] = useState<Record<string, unknown>[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.source) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
;(window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<string> } })
|
||||
.ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' })
|
||||
.then((content: string) => {
|
||||
const parsed = JSON.parse(content)
|
||||
if (Array.isArray(parsed)) {
|
||||
setFileData(parsed)
|
||||
} else {
|
||||
setError('Source file must contain a JSON array')
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message || 'Failed to load data file')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [config?.source])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
|
||||
<div className="chart-block-card chart-block-error">
|
||||
<BarChart3 size={16} />
|
||||
<span>Invalid chart block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const data = config.data || fileData
|
||||
|
||||
const renderChart = () => {
|
||||
if (loading) return <div className="chart-block-loading">Loading data...</div>
|
||||
if (error) return <div className="chart-block-error-msg">{error}</div>
|
||||
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{config!.chart === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={config!.x} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
|
||||
</LineChart>
|
||||
) : config!.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={config!.x} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={config!.y} fill="#8884d8" />
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
|
||||
<div className="chart-block-card">
|
||||
<button
|
||||
className="chart-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete chart block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="chart-block-title">{config.title}</div>}
|
||||
{renderChart()}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChartBlockExtension = Node.create({
|
||||
name: 'chartBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-chart')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChartBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```chart\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
143
apps/x/apps/renderer/src/extensions/embed-block.tsx
Normal file
143
apps/x/apps/renderer/src/extensions/embed-block.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ExternalLink } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function getEmbedUrl(provider: string, url: string): string | null {
|
||||
if (provider === 'youtube') {
|
||||
// Handle youtube.com/watch?v=X and youtu.be/X
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
|
||||
if (match) return `https://www.youtube.com/embed/${match[1]}`
|
||||
}
|
||||
if (provider === 'figma') {
|
||||
// Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat
|
||||
const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/)
|
||||
if (figmaMatch) {
|
||||
return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat`
|
||||
}
|
||||
// Legacy /file/ URLs
|
||||
const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/)
|
||||
if (legacyMatch) {
|
||||
return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.EmbedBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.EmbedBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
|
||||
<div className="embed-block-card embed-block-error">
|
||||
<ExternalLink size={16} />
|
||||
<span>Invalid embed block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const embedUrl = getEmbedUrl(config.provider, config.url)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
|
||||
<div className="embed-block-card">
|
||||
<button
|
||||
className="embed-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete embed block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{embedUrl ? (
|
||||
<div className="embed-block-iframe-container">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
className="embed-block-iframe"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={config.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="embed-block-link"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{config.url}
|
||||
</a>
|
||||
)}
|
||||
{config.caption && (
|
||||
<div className="embed-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmbedBlockExtension = Node.create({
|
||||
name: 'embedBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-embed')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'embed-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmbedBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```embed\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
104
apps/x/apps/renderer/src/extensions/image-block.tsx
Normal file
104
apps/x/apps/renderer/src/extensions/image-block.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, ImageIcon } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function ImageBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.ImageBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.ImageBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
|
||||
<div className="image-block-card image-block-error">
|
||||
<ImageIcon size={16} />
|
||||
<span>Invalid image block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
|
||||
<div className="image-block-card">
|
||||
<button
|
||||
className="image-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete image block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={config.src}
|
||||
alt={config.alt || ''}
|
||||
className="image-block-img"
|
||||
/>
|
||||
{config.caption && (
|
||||
<div className="image-block-caption">{config.caption}</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageBlockExtension = Node.create({
|
||||
name: 'imageBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-image')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```image\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
124
apps/x/apps/renderer/src/extensions/table-block.tsx
Normal file
124
apps/x/apps/renderer/src/extensions/table-block.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { X, Table2 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
|
||||
function TableBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.TableBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.TableBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
|
||||
<div className="table-block-card table-block-error">
|
||||
<Table2 size={16} />
|
||||
<span>Invalid table block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
|
||||
<div className="table-block-card">
|
||||
<button
|
||||
className="table-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete table block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{config.title && <div className="table-block-title">{config.title}</div>}
|
||||
<div className="table-block-scroll">
|
||||
<table className="table-block-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{config.columns.map((col) => (
|
||||
<th key={col}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{config!.columns.map((col) => (
|
||||
<td key={col}>{String(row[col] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{config.data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={config.columns.length} className="table-block-empty">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TableBlockExtension = Node.create({
|
||||
name: 'tableBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-table')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'table-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TableBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```table\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -608,6 +608,230 @@
|
|||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
cursor: default;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover {
|
||||
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Image block */
|
||||
.tiptap-editor .ProseMirror .image-block-img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-caption {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .image-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Embed block */
|
||||
.tiptap-editor .ProseMirror .embed-block-iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-caption {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .embed-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Chart block */
|
||||
.tiptap-editor .ProseMirror .chart-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-loading,
|
||||
.tiptap-editor .ProseMirror .chart-block-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-error-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .chart-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Table block */
|
||||
.tiptap-editor .ProseMirror .table-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-empty {
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
padding: 16px 10px !important;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .table-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .tiptap-editor .ProseMirror {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue