chore: commit remaining workspace changes
This commit is contained in:
@@ -20,7 +20,6 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
|
||||
const isEdited = post?.is_edited
|
||||
const postId = post?.id
|
||||
const threadSlug = thread?.slug
|
||||
|
||||
const handleReaction = async (reaction) => {
|
||||
if (reacting || !isAuthenticated) return
|
||||
setReacting(true)
|
||||
|
||||
@@ -46,6 +46,21 @@ function Divider() {
|
||||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||
}
|
||||
|
||||
function getRootFontSizePx() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 16
|
||||
}
|
||||
|
||||
return Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||
}
|
||||
|
||||
function formatViewportHeightLabel(value) {
|
||||
const rounded = Number(value || 0)
|
||||
const displayValue = Number.isInteger(rounded) ? rounded : Number(rounded.toFixed(1))
|
||||
|
||||
return `${displayValue}rem`
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(rawValue) {
|
||||
const trimmed = String(rawValue || '').trim()
|
||||
if (trimmed === '') {
|
||||
@@ -922,10 +937,16 @@ function AssetPickerDialog({
|
||||
function Toolbar({
|
||||
editor,
|
||||
advancedNews = false,
|
||||
sourceMode = false,
|
||||
activeSourceMode = null,
|
||||
sourceModeLabel = 'HTML',
|
||||
sourceModeTitle = 'View or edit source HTML',
|
||||
secondarySourceModeLabel = null,
|
||||
secondarySourceModeTitle = '',
|
||||
showStructureOutlines = false,
|
||||
showComparisonTool = false,
|
||||
fullHeightMode = false,
|
||||
onToggleSourceMode,
|
||||
onToggleSecondarySourceMode,
|
||||
onToggleStructureOutlines,
|
||||
onInsertArtwork,
|
||||
onInsertImage,
|
||||
@@ -937,7 +958,7 @@ function Toolbar({
|
||||
editorViewportHeight,
|
||||
onIncreaseEditorViewportHeight,
|
||||
onDecreaseEditorViewportHeight,
|
||||
onResetEditorViewportHeight,
|
||||
onToggleFullHeightMode,
|
||||
}) {
|
||||
if (!editor) return null
|
||||
|
||||
@@ -1027,9 +1048,14 @@ function Toolbar({
|
||||
{advancedNews ? (
|
||||
<>
|
||||
<Divider />
|
||||
<ToolbarBtn onClick={onToggleSourceMode} active={sourceMode} title="View or edit source HTML" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">HTML</span>
|
||||
<ToolbarBtn onClick={onToggleSourceMode} active={activeSourceMode === 'primary'} title={sourceModeTitle} className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{sourceModeLabel}</span>
|
||||
</ToolbarBtn>
|
||||
{secondarySourceModeLabel ? (
|
||||
<ToolbarBtn onClick={onToggleSecondarySourceMode} active={activeSourceMode === 'secondary'} title={secondarySourceModeTitle} className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">{secondarySourceModeLabel}</span>
|
||||
</ToolbarBtn>
|
||||
) : null}
|
||||
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
|
||||
</ToolbarBtn>
|
||||
@@ -1053,12 +1079,12 @@ function Toolbar({
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A-</span>
|
||||
</ToolbarBtn>
|
||||
<div className="mx-1 flex min-w-[5.25rem] items-center justify-center rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
{editorViewportHeight}rem
|
||||
{formatViewportHeightLabel(editorViewportHeight)}
|
||||
</div>
|
||||
<ToolbarBtn onClick={onIncreaseEditorViewportHeight} title="Taller editor" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">A+</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onResetEditorViewportHeight} title="Reset editor height" className="w-auto px-2.5">
|
||||
<ToolbarBtn onClick={onToggleFullHeightMode} active={fullHeightMode} title={fullHeightMode ? 'Exit full height editor' : 'Expand editor to full browser size'} className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Fit</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
|
||||
@@ -1078,16 +1104,26 @@ export default function RichTextEditor({
|
||||
placeholder = 'Write something…',
|
||||
error,
|
||||
minHeight = 12,
|
||||
maxHeightRem = 42,
|
||||
autofocus = false,
|
||||
advancedNews = false,
|
||||
sourceModeLabel = 'HTML',
|
||||
sourceModeTitle = 'View or edit source HTML',
|
||||
sourceModeDescription = 'Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.',
|
||||
secondarySourceModeLabel = null,
|
||||
secondarySourceModeTitle = '',
|
||||
secondarySourceModeDescription = '',
|
||||
secondarySourceModeValue = null,
|
||||
onSecondarySourceModeValueChange = null,
|
||||
searchEntities = null,
|
||||
mediaSupport = null,
|
||||
}) {
|
||||
const viewportStorageKey = 'rich-text-editor.viewport-height'
|
||||
const viewportMinHeight = Math.max(minHeight + 6, 18)
|
||||
const viewportMaxHeight = 42
|
||||
const viewportMaxHeight = Math.max(viewportMinHeight, Number(maxHeightRem) || 42)
|
||||
const viewportStep = 4
|
||||
const [sourceMode, setSourceMode] = useState(false)
|
||||
const [activeSourceMode, setActiveSourceMode] = useState(null)
|
||||
const [fullHeightMode, setFullHeightMode] = useState(false)
|
||||
const [sourceValue, setSourceValue] = useState(String(content || ''))
|
||||
const [showStructureOutlines, setShowStructureOutlines] = useState(false)
|
||||
const [helperMessage, setHelperMessage] = useState('')
|
||||
@@ -1142,6 +1178,8 @@ export default function RichTextEditor({
|
||||
return Math.min(viewportMaxHeight, viewportMinHeight)
|
||||
})
|
||||
const editorRef = useRef(null)
|
||||
const resizeCleanupRef = useRef(null)
|
||||
const usesSecondarySourceMode = typeof onSecondarySourceModeValueChange === 'function' && secondarySourceModeValue != null
|
||||
const csrfToken = useMemo(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
@@ -1491,7 +1529,7 @@ export default function RichTextEditor({
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
if (!sourceMode) {
|
||||
if (!activeSourceMode) {
|
||||
onChange?.(currentEditor.getHTML())
|
||||
}
|
||||
},
|
||||
@@ -1520,7 +1558,7 @@ export default function RichTextEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (sourceMode) return
|
||||
if (activeSourceMode) return
|
||||
if ((content || '') === editor.getHTML()) return
|
||||
|
||||
editor.commands.setContent(content || '', false)
|
||||
@@ -1529,13 +1567,13 @@ export default function RichTextEditor({
|
||||
if (normalizedHtml !== (content || '')) {
|
||||
onChange?.(normalizedHtml)
|
||||
}
|
||||
}, [content, editor, onChange, sourceMode])
|
||||
}, [activeSourceMode, content, editor, onChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode) {
|
||||
if (activeSourceMode === 'primary') {
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
}
|
||||
}, [content, editor, sourceMode])
|
||||
}, [activeSourceMode, content, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -1544,6 +1582,42 @@ export default function RichTextEditor({
|
||||
window.localStorage.setItem(viewportStorageKey, String(editorViewportHeight))
|
||||
}, [editorViewportHeight])
|
||||
|
||||
const stopViewportResize = useCallback(() => {
|
||||
if (resizeCleanupRef.current) {
|
||||
resizeCleanupRef.current()
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => stopViewportResize, [stopViewportResize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullHeightMode || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setFullHeightMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [fullHeightMode])
|
||||
|
||||
const decreaseEditorViewportHeight = useCallback(() => {
|
||||
setEditorViewportHeight((current) => Math.max(viewportMinHeight, Number((current - viewportStep).toFixed(1))))
|
||||
}, [viewportMinHeight, viewportStep])
|
||||
@@ -1552,27 +1626,98 @@ export default function RichTextEditor({
|
||||
setEditorViewportHeight((current) => Math.min(viewportMaxHeight, Number((current + viewportStep).toFixed(1))))
|
||||
}, [viewportMaxHeight, viewportStep])
|
||||
|
||||
const resetEditorViewportHeight = useCallback(() => {
|
||||
setEditorViewportHeight(Math.min(viewportMaxHeight, viewportMinHeight))
|
||||
}, [viewportMaxHeight, viewportMinHeight])
|
||||
const toggleFullHeightMode = useCallback(() => {
|
||||
setFullHeightMode((current) => !current)
|
||||
}, [])
|
||||
|
||||
const startViewportResize = useCallback((event) => {
|
||||
if (fullHeightMode || event.button !== 0 || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const startY = event.clientY
|
||||
const startHeight = editorViewportHeight
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
const deltaRem = (moveEvent.clientY - startY) / getRootFontSizePx()
|
||||
const nextHeight = Number((startHeight + deltaRem).toFixed(1))
|
||||
|
||||
setEditorViewportHeight(Math.min(viewportMaxHeight, Math.max(viewportMinHeight, nextHeight)))
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
stopViewportResize()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
window.addEventListener('pointercancel', handlePointerUp)
|
||||
resizeCleanupRef.current = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
window.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'ns-resize'
|
||||
}, [editorViewportHeight, fullHeightMode, stopViewportResize, viewportMaxHeight, viewportMinHeight])
|
||||
|
||||
const pushHelperMessage = useCallback((message) => {
|
||||
setHelperMessage(message)
|
||||
}, [])
|
||||
|
||||
const commitPrimarySourceToEditor = useCallback(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceValue || '', false)
|
||||
}
|
||||
}, [editor, sourceValue])
|
||||
|
||||
const handleToggleSourceMode = useCallback(() => {
|
||||
if (sourceMode) {
|
||||
setSourceMode(false)
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceValue || '', false)
|
||||
}
|
||||
if (activeSourceMode === 'primary') {
|
||||
setActiveSourceMode(null)
|
||||
commitPrimarySourceToEditor()
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(editor?.getHTML() || String(content || ''))
|
||||
setSourceMode(true)
|
||||
}, [content, editor, pushHelperMessage, sourceMode, sourceValue])
|
||||
if (activeSourceMode === 'secondary') {
|
||||
setActiveSourceMode('primary')
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
setActiveSourceMode('primary')
|
||||
}, [activeSourceMode, commitPrimarySourceToEditor, content, editor, pushHelperMessage])
|
||||
|
||||
const handleToggleSecondarySourceMode = useCallback(() => {
|
||||
if (!usesSecondarySourceMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSourceMode === 'secondary') {
|
||||
setActiveSourceMode(null)
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSourceMode === 'primary') {
|
||||
commitPrimarySourceToEditor()
|
||||
}
|
||||
|
||||
setActiveSourceMode('secondary')
|
||||
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage, usesSecondarySourceMode])
|
||||
|
||||
const handleCloseSourceMode = useCallback(() => {
|
||||
if (activeSourceMode === 'primary') {
|
||||
commitPrimarySourceToEditor()
|
||||
}
|
||||
|
||||
setActiveSourceMode(null)
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
}, [activeSourceMode, commitPrimarySourceToEditor, pushHelperMessage])
|
||||
|
||||
const insertArtworkEmbed = useCallback((item) => {
|
||||
if (!editor || !item) return
|
||||
@@ -1867,23 +2012,52 @@ export default function RichTextEditor({
|
||||
editor.chain().focus().insertContent(`#${value}`).run()
|
||||
}, [editor])
|
||||
|
||||
const shellClassName = fullHeightMode
|
||||
? 'fixed inset-0 z-[980] flex min-h-0 w-screen flex-col bg-[#04070df2] p-3 backdrop-blur-sm md:p-4'
|
||||
: 'flex w-full min-w-0 flex-col gap-1.5'
|
||||
|
||||
const bodyClassName = fullHeightMode
|
||||
? 'flex min-h-0 w-full flex-1 flex-col gap-1.5'
|
||||
: 'flex w-full min-w-0 flex-col gap-1.5'
|
||||
|
||||
const editorCardClassName = [
|
||||
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
fullHeightMode ? 'flex min-h-0 flex-1 flex-col rounded-2xl' : '',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
const editorViewportStyle = fullHeightMode
|
||||
? { flex: 1 }
|
||||
: { height: `${editorViewportHeight}rem` }
|
||||
|
||||
const sourceTextareaStyle = fullHeightMode
|
||||
? { flex: 1 }
|
||||
: {
|
||||
height: `${Math.max(minHeight, editorViewportHeight)}rem`,
|
||||
minHeight: `${Math.max(minHeight, 20)}rem`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1.5">
|
||||
<div className={shellClassName}>
|
||||
<div className={bodyClassName}>
|
||||
<div
|
||||
className={[
|
||||
'news-rich-text-editor w-full min-w-0 overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].join(' ')}
|
||||
className={editorCardClassName}
|
||||
>
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
advancedNews={advancedNews}
|
||||
sourceMode={sourceMode}
|
||||
activeSourceMode={activeSourceMode}
|
||||
sourceModeLabel={sourceModeLabel}
|
||||
sourceModeTitle={sourceModeTitle}
|
||||
secondarySourceModeLabel={secondarySourceModeLabel}
|
||||
secondarySourceModeTitle={secondarySourceModeTitle}
|
||||
showStructureOutlines={showStructureOutlines}
|
||||
showComparisonTool={Boolean(mediaSupport?.uploadUrl)}
|
||||
fullHeightMode={fullHeightMode}
|
||||
onToggleSourceMode={handleToggleSourceMode}
|
||||
onToggleSecondarySourceMode={handleToggleSecondarySourceMode}
|
||||
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
|
||||
onInsertArtwork={handleInsertArtwork}
|
||||
onInsertImage={handleInsertImage}
|
||||
@@ -1895,44 +2069,65 @@ export default function RichTextEditor({
|
||||
editorViewportHeight={editorViewportHeight}
|
||||
onIncreaseEditorViewportHeight={increaseEditorViewportHeight}
|
||||
onDecreaseEditorViewportHeight={decreaseEditorViewportHeight}
|
||||
onResetEditorViewportHeight={resetEditorViewportHeight}
|
||||
onToggleFullHeightMode={toggleFullHeightMode}
|
||||
/>
|
||||
|
||||
{advancedNews && sourceMode ? (
|
||||
<div className="border-t border-white/[0.04] bg-black/10 px-4 py-3">
|
||||
{advancedNews && activeSourceMode ? (
|
||||
<div className={[
|
||||
'border-t border-white/[0.04] bg-black/10 px-4 py-3',
|
||||
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
|
||||
].filter(Boolean).join(' ')}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span>Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.</span>
|
||||
<button type="button" onClick={handleToggleSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>{activeSourceMode === 'secondary' ? secondarySourceModeDescription : sourceModeDescription}</span>
|
||||
<button type="button" onClick={handleCloseSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
Back to visual
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={sourceValue}
|
||||
value={activeSourceMode === 'secondary' ? String(secondarySourceModeValue || '') : sourceValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
if (activeSourceMode === 'secondary') {
|
||||
onSecondarySourceModeValueChange?.(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(nextValue)
|
||||
onChange?.(nextValue)
|
||||
}}
|
||||
spellCheck={false}
|
||||
className="nova-scrollbar min-h-[20rem] w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
style={{ minHeight: `${Math.max(minHeight, 20)}rem` }}
|
||||
className="nova-scrollbar w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
style={sourceTextareaStyle}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={[
|
||||
'rich-text-editor-viewport nova-scrollbar w-full min-w-0 border-t border-white/[0.04] bg-black/15',
|
||||
fullHeightMode ? 'flex min-h-0 flex-1 flex-col' : '',
|
||||
advancedNews && showStructureOutlines ? 'news-editor-outline' : '',
|
||||
].filter(Boolean).join(' ')} style={{ maxHeight: `${editorViewportHeight}rem` }}>
|
||||
].filter(Boolean).join(' ')} style={editorViewportStyle}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullHeightMode ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize editor height"
|
||||
title="Drag to resize editor height"
|
||||
onPointerDown={startViewportResize}
|
||||
className="group flex h-5 w-full cursor-row-resize items-center justify-center border-t border-white/[0.04] bg-black/10 text-slate-500 transition hover:bg-white/[0.03] hover:text-slate-300"
|
||||
>
|
||||
<span className="h-1 w-16 rounded-full bg-current opacity-70 transition group-hover:opacity-100" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{advancedNews && helperMessage ? (
|
||||
<p className="text-xs text-sky-300">{helperMessage}</p>
|
||||
) : null}
|
||||
|
||||
{!sourceMode ? (
|
||||
{!activeSourceMode ? (
|
||||
<RichTableControls editor={editor} />
|
||||
) : null}
|
||||
|
||||
@@ -2028,6 +2223,7 @@ export default function RichTextEditor({
|
||||
onClose={() => setTableInsertOpen(false)}
|
||||
onInsert={handleTableInsert}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export default function ThreadRow({ thread, isFirst = false }) {
|
||||
const isPinned = thread?.is_pinned ?? false
|
||||
|
||||
const href = `/forum/topic/${slug}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
||||
Reference in New Issue
Block a user