fancy text editor
This commit is contained in:
parent
db1c642072
commit
82361fdf63
6 changed files with 119 additions and 11 deletions
|
@ -2,7 +2,7 @@ import { BrowserRouter, Route, Routes } from 'react-router'
|
||||||
import HomePage from './pages/HomePage.tsx'
|
import HomePage from './pages/HomePage.tsx'
|
||||||
import { PostsService } from './model/posts/postsService.ts'
|
import { PostsService } from './model/posts/postsService.ts'
|
||||||
import AuthorPage from './pages/AuthorPage.tsx'
|
import AuthorPage from './pages/AuthorPage.tsx'
|
||||||
import { MediaService } from './pages/mediaService.ts'
|
import { MediaService } from './model/mediaService.ts'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const postService = new PostsService()
|
const postService = new PostsService()
|
||||||
|
|
95
src/components/FancyTextEditor.tsx
Normal file
95
src/components/FancyTextEditor.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { useRef, useEffect, KeyboardEvent, useState } from 'react'
|
||||||
|
|
||||||
|
interface TextInputProps {
|
||||||
|
value: string
|
||||||
|
onInput: (value: string) => void
|
||||||
|
onKeyDown: (e: TextInputKeyDownEvent) => void
|
||||||
|
className?: string
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextInputKeyDownEvent {
|
||||||
|
key: string
|
||||||
|
ctrlKey: boolean
|
||||||
|
preventDefault: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FancyTextEditor({
|
||||||
|
value: _value,
|
||||||
|
onInput,
|
||||||
|
onKeyDown,
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
}: TextInputProps) {
|
||||||
|
const divRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [hasFocus, setHasFocus] = useState(false)
|
||||||
|
|
||||||
|
// the contenteditable likes to slip in newlines at the bottom of our innerText
|
||||||
|
// which makes it bad to check for empty string because it might be "\n"
|
||||||
|
// so we just trim it upfront and then fogeddaboudit
|
||||||
|
const value = _value.trim()
|
||||||
|
|
||||||
|
// The funky mechanics here are to stop the cursor from jumping back the start.
|
||||||
|
// It probably will have the cursor jump to the start if anything changes programmatically,
|
||||||
|
// which is probably unnecessary anyway
|
||||||
|
useEffect(() => {
|
||||||
|
const div = divRef.current
|
||||||
|
if (div == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value && !hasFocus) {
|
||||||
|
div.innerText = placeholder ?? ''
|
||||||
|
} else if (div.innerText !== value) {
|
||||||
|
div.innerText = value
|
||||||
|
}
|
||||||
|
}, [hasFocus, placeholder, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const div = divRef.current!
|
||||||
|
if (div == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputListener = () => {
|
||||||
|
onInput(div.innerText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blurListener = () => {
|
||||||
|
setHasFocus(false)
|
||||||
|
if (!value) {
|
||||||
|
div.innerText = placeholder ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusListener = () => {
|
||||||
|
setHasFocus(true)
|
||||||
|
div.innerText = value
|
||||||
|
}
|
||||||
|
|
||||||
|
div.addEventListener('input', inputListener)
|
||||||
|
div.addEventListener('blur', blurListener)
|
||||||
|
div.addEventListener('focus', focusListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
div.removeEventListener('focus', focusListener)
|
||||||
|
div.removeEventListener('blur', blurListener)
|
||||||
|
div.removeEventListener('input', inputListener)
|
||||||
|
}
|
||||||
|
}, [onInput, placeholder, value])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
onKeyDown(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const textColour = value ? 'text-gray-900' : 'text-gray-500'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={divRef}
|
||||||
|
className={`w-full p-3 resize-none border border-gray-200 rounded-md focus:outline-none focus:border-gray-300 ${textColour} min-h-30 ${className ?? ''}`}
|
||||||
|
contentEditable
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
></div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, ChangeEvent } from 'react'
|
import { useState, ChangeEvent } from 'react'
|
||||||
|
import FancyTextEditor, { TextInputKeyDownEvent } from './FancyTextEditor.tsx'
|
||||||
|
|
||||||
interface NewPostWidgetProps {
|
interface NewPostWidgetProps {
|
||||||
onSubmit: (content: string, media: File[]) => void
|
onSubmit: (content: string, media: File[]) => void
|
||||||
|
@ -15,8 +16,8 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
|
||||||
const handleContentChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const onContentInput = (value: string) => {
|
||||||
setContent(e.target.value)
|
setContent(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMediaChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleMediaChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
@ -56,15 +57,21 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
URL.revokeObjectURL(attachment.objectUrl)
|
URL.revokeObjectURL(attachment.objectUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onInputKeyDown = (e: TextInputKeyDownEvent) => {
|
||||||
|
if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-4 border-b border-gray-200">
|
<div className="w-full p-4 border-b border-gray-200">
|
||||||
<textarea
|
<FancyTextEditor
|
||||||
className="w-full p-2 mb-3 resize-none border border-gray-200 rounded-md focus:outline-none focus:border-gray-300"
|
|
||||||
placeholder="Write something..."
|
|
||||||
value={content}
|
value={content}
|
||||||
onChange={handleContentChange}
|
onInput={onContentInput}
|
||||||
rows={3}
|
onKeyDown={onInputKeyDown}
|
||||||
disabled={isSubmitting}
|
className="mb-3"
|
||||||
|
placeholder="write something..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
|
|
|
@ -45,7 +45,13 @@ export default function PostItem({ post, index }: PostItemProps) {
|
||||||
{post.media.length > 0 && (
|
{post.media.length > 0 && (
|
||||||
<div className="grid gap-4 grid-cols-1">
|
<div className="grid gap-4 grid-cols-1">
|
||||||
{post.media.map((src) => (
|
{post.media.map((src) => (
|
||||||
<img key={src} src={src} alt="" className="w-full h-auto" loading="lazy" />
|
<img
|
||||||
|
key={src.toString()}
|
||||||
|
src={src.toString()}
|
||||||
|
alt="todo sry :("
|
||||||
|
className="w-full h-auto"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
|
||||||
import FeedView from '../feed/FeedView.tsx'
|
import FeedView from '../feed/FeedView.tsx'
|
||||||
import { PostsService } from '../model/posts/postsService.ts'
|
import { PostsService } from '../model/posts/postsService.ts'
|
||||||
import { useUserStore } from '../store/userStore.ts'
|
import { useUserStore } from '../store/userStore.ts'
|
||||||
import { MediaService } from './mediaService.ts'
|
import { MediaService } from '../model/mediaService.ts'
|
||||||
import NewPostWidget from '../components/NewPostWidget.tsx'
|
import NewPostWidget from '../components/NewPostWidget.tsx'
|
||||||
import { useFeedViewModel } from '../feed/feedViewModel.ts'
|
import { useFeedViewModel } from '../feed/feedViewModel.ts'
|
||||||
import { Post } from '../model/posts/posts.ts'
|
import { Post } from '../model/posts/posts.ts'
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue