pull/50/head
sunface 4 years ago
parent 63752a0e79
commit a53ffd4fde

@ -38,7 +38,7 @@
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-icons": "^4.1.0",
"react-markdown-editor-lite": "^1.2.4",
"react-markdown-editor-lite-sunface": "^1.2.5",
"validator": "^13.5.2"
},
"devDependencies": {

@ -0,0 +1,63 @@
import useCaretPosition from 'components/markdown-editor/position'
import TestStyles from 'theme/caret.styles'
import React, { useRef, useState, useEffect, Fragment } from 'react'
import { render } from 'react-dom'
const App = () => {
const triggerRef = useRef(null)
const [showTrigger, setShowTrigger] = useState(false)
const {
x: triggerX,
y: triggerY,
getPosition: getPositionTrigger,
} = useCaretPosition(triggerRef)
const handleCustomUI = (e) => {
const previousCharacter = e.target.value
.charAt(triggerRef.current.selectionStart - 2)
.trim()
const character = e.target.value
.charAt(triggerRef.current.selectionStart - 1)
.trim()
if (character === '@' && previousCharacter === '') {
setShowTrigger(true)
}
if (character === '' && showTrigger) {
setShowTrigger(false)
}
}
useEffect(() => {
if (triggerRef.current) {
getPositionTrigger(triggerRef)
}
}, [])
return (
<>
<TestStyles />
<section>
<textarea
ref={triggerRef}
placeholder="Type the @ symbol to trigger UI"
spellCheck="false"
onKeyUp={handleCustomUI}
onInput={() => getPositionTrigger(triggerRef)}
/>
<span
className="marker marker--trigger"
style={{
display: showTrigger ? 'block' : 'none',
//@ts-ignore
'--y': triggerY,
'--x': triggerX,
}}>
Triggered UI! <span role="img">😎</span>
</span>
</section>
</>
)
}
export default App

@ -46,7 +46,7 @@ export const CommentCard = (props: Props) => {
return (
<>{
editorVisible ? (user && <CommentEditor user={user} md={comment.md} onSubmit={md => {setEditorVisible(false);changeComment(md)}} onCancel={() => setEditorVisible(false)} />) :
editorVisible ? (user && <CommentEditor user={user} md={comment.md} onSubmit={md => {setEditorVisible(false);changeComment(md)}} onCancel={() => setEditorVisible(false)} menu={false}/>) :
<Card>
<VStack alignItems="left">
<Flex justifyContent="space-between" width="100%" alignItems="top">
@ -95,12 +95,12 @@ export const CommentCard = (props: Props) => {
</HStack>
</Flex>
{replyVisible && <Box pl="16" pr="2"><CommentEditor user={user} md={reply} onSubmit={md => {submitReply(md)}} onCancel={() => setReplyVisible(false)} /></Box>}
{replyVisible && <Box pl="16" pr="2"><CommentEditor menu={false} user={user} md={reply} onSubmit={md => {submitReply(md)}} onCancel={() => setReplyVisible(false)} /></Box>}
{comment.replies.map(reply =>
<Box pl="16" key={reply.id}>
<Divider mb="4"/>
<Reply user={props.user} onChange={props.onChange} comment={reply}/>
<Reply user={props.user} onChange={props.onChange} comment={reply} parent={comment}/>
</Box>)}
</VStack>
</Card>}

@ -5,10 +5,11 @@ import useSession from "hooks/use-session"
import { requestApi } from "utils/axios/request"
import CommentCard from "./comment"
import CommentEditor from "./editor"
import { Comment } from "src/types/comments"
interface Props {
storyID: string
comments: any[]
comments: Comment[]
onChange: any
}
export const Comments = ({storyID, comments,onChange }: Props) => {
@ -20,12 +21,21 @@ export const Comments = ({storyID, comments,onChange }: Props) => {
onChange()
}
const countComments = () => {
let n = comments.length
for (const c of comments) {
n += c.replies.length
}
return n
}
return (
<VStack spacing="4" alignItems="left">
<Card>
<Flex justifyContent="space-between">
<HStack>
<Text fontWeight="600" fontSize="1.1rem">Comments ({comments.length})</Text>
<Text fontWeight="600" fontSize="1.1rem">Comments ({countComments()})</Text>
</HStack>
<Button variant="outline" colorScheme="teal" onClick={() => setEditorVisible(true)} _focus={null}>Add comment</Button>

@ -15,6 +15,7 @@ interface Props {
md: string
onSubmit: any
onCancel: any
menu?: boolean
}
export const CommentEditor = (props: Props) => {
@ -31,7 +32,7 @@ export const CommentEditor = (props: Props) => {
<EditModeSelect onChange={m => setEditMode(m)}/>
</Flex>
<Box mt="4" h="300px">
{editMode===EditMode.Edit ? <MarkdownEditor md={md} onChange={md => setMd(md)}/> : <MarkdownRender md={md} overflowY="scroll"/>}
{editMode===EditMode.Edit ? <MarkdownEditor menu={props.menu?? true} md={md} onChange={md => setMd(md)}/> : <MarkdownRender md={md} overflowY="scroll"/>}
</Box>
<Flex justifyContent="flex-end">

@ -14,17 +14,19 @@ import { requestApi } from "utils/axios/request"
interface Props {
user: User
comment: Comment
parent: Comment
onChange: any
}
export const Reply = (props: Props) => {
const { comment, user,onChange} = props
const { comment, user,onChange,parent} = props
const [editorVisible, setEditorVisible] = useState(false)
const [replyVisible,setReplyVisible] = useState(false)
const [reply,setReply] = useState('')
const submitReply = async (md) => {
await requestApi.post('/story/comment',{targetID: comment.id, md: md})
await requestApi.post('/story/comment',{targetID: parent.id, md: md})
setReplyVisible(false);
onChange()
}
const changeReply = async (md) => {
@ -44,9 +46,19 @@ export const Reply = (props: Props) => {
}
const replyToReply = () => {
if (comment.creator.nickname === "") {
setReply(`@${comment.creator.username}`)
} else {
setReply(`[@${comment.creator.nickname}](/${comment.creator.username})`)
}
setReplyVisible(!replyVisible)
}
return (
<>{
editorVisible ? (user && <CommentEditor user={user} md={comment.md} onSubmit={md => {setEditorVisible(false);changeReply(md)}} onCancel={() => setEditorVisible(false)} />) :
editorVisible ? (user && <CommentEditor user={user} md={comment.md} onSubmit={md => {setEditorVisible(false);changeReply(md)}} onCancel={() => setEditorVisible(false)} menu={false} />) :
<VStack alignItems="left">
<Flex width="100%" alignItems="center" justifyContent="space-between">
<HStack spacing="4">
@ -69,7 +81,7 @@ export const Reply = (props: Props) => {
_focus={null}
color="gray.500"
icon={<FaReply />}
onClick={() => setReplyVisible(!replyVisible)}
onClick={replyToReply}
fontSize="18px"
/>}
@ -95,7 +107,7 @@ export const Reply = (props: Props) => {
{replyVisible &&
<Box pl="16" pr="2">
<CommentEditor user={user} md={reply} onSubmit={md => {submitReply(md)}} onCancel={() => setReplyVisible(false)} />
<CommentEditor user={user} md={reply} onSubmit={md => {submitReply(md)}} onCancel={() => setReplyVisible(false)} menu={false}/>
</Box>}
</VStack>}
</>

@ -1,11 +1,13 @@
import React from 'react';
/*eslint-disable*/
import React, { useRef, useEffect,useState} from 'react';
import 'highlight.js/styles/atom-one-dark.css';
import { chakra,PropsOf} from '@chakra-ui/react';
import { chakra,Popover,PopoverTrigger,PopoverContent,PopoverBody,Box,PropsOf, useDisclosure} from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import 'react-markdown-editor-lite/lib/index.css';
const MdEditor = dynamic(() => import('react-markdown-editor-lite'), {
import 'react-markdown-editor-lite-sunface/lib/index.css';
import useCaretPosition from './position'
import CaretStyles from 'theme/caret.styles'
const MdEditor = dynamic(() => import('node_modules/react-markdown-editor-lite-sunface'), {
ssr: false
});
@ -14,17 +16,52 @@ const MdEditor = dynamic(() => import('react-markdown-editor-lite'), {
type Props = PropsOf<typeof chakra.div> & {
md: string
onChange: any
menu?: boolean
}
export function MarkdownEditor(props) {
export function MarkdownEditor(props:Props) {
const { onOpen, onClose, isOpen } = useDisclosure()
function handleEditorChange({html, text}) {
props.onChange(text)
onOpen()
}
const triggerRef = useRef(null)
const [showTrigger, setShowTrigger] = useState(false)
const {
x: triggerX,
y: triggerY,
getPosition: getPositionTrigger,
} = useCaretPosition(triggerRef)
const handleCustomUI = (e) => {
const previousCharacter = e.target.value
.charAt(triggerRef.current.selectionStart - 2)
.trim()
const character = e.target.value
.charAt(triggerRef.current.selectionStart - 1)
.trim()
if (character === '@' && previousCharacter === '') {
setShowTrigger(true)
}
if (character === '' && showTrigger) {
setShowTrigger(false)
}
}
useEffect(() => {
if (triggerRef.current) {
getPositionTrigger(triggerRef)
}
}, [])
return (
<MdEditor
<>
{/* <MdEditor
height="100%"
width="100%"
value={props.md}
@ -34,13 +71,40 @@ export function MarkdownEditor(props) {
config={{
canView: false,
view:{
menu: true,
menu: props.menu ?? true,
md: true,
html: false,
fullScreen: true,
}
}}
/> */}
<CaretStyles />
<textarea
ref={triggerRef}
placeholder="Type the @ symbol to trigger UI"
spellCheck="false"
onKeyUp={handleCustomUI}
onInput={() => getPositionTrigger(triggerRef)}
/>
<span
className="marker marker--trigger"
style={{
display: showTrigger ? 'block' : 'none',
//@ts-ignore
'--y': triggerY,
'--x': triggerX,
}}>
Triggered UI! <span role="img">😎</span>
</span>
<Popover isOpen={isOpen} closeOnBlur={false} placement="bottom-start" onOpen={onOpen} onClose={onClose} autoFocus={false}>
<PopoverTrigger><Box width="100%"></Box></PopoverTrigger>
<PopoverContent width="100%" transform="translate3d(176px, 2071px, 0px)">
<PopoverBody width="100%" p="0">
ssssss
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}

@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react'
/**
* returns x, y coordinates for absolute positioning of a span within a given text input
* at a given selection point
* @param {object} input - the input element to obtain coordinates for
* @param {number} selectionPoint - the selection point for the input
*/
const getCaretPosition = (input, selection = 'selectionStart') => {
const { scrollLeft, scrollTop } = input
// This provides a hook for getSelection to reuse getCaretPosition.
const selectionPoint = input[selection] || input.selectionStart
const { height, width, left, top } = input.getBoundingClientRect()
// create a dummy element that will be a clone of our input
const div = document.createElement('div')
// get the computed style of the input and clone it onto the dummy element
const copyStyle = getComputedStyle(input)
for (const prop of copyStyle) {
div.style[prop] = copyStyle[prop]
}
// we need a character that will replace whitespace when filling our dummy element if it's a single line <input/>
const swap = '.'
const inputValue =
input.tagName === 'INPUT' ? input.value.replace(/ /g, swap) : input.value
// set the div content to that of the textarea up until selection
const textContent = inputValue.substr(0, selectionPoint)
// set the text content of the dummy element div
div.textContent = textContent
if (input.tagName === 'TEXTAREA') div.style.height = 'auto'
// if a single line input then the div needs to be single line and not break out like a text area
if (input.tagName === 'INPUT') div.style.width = 'auto'
// Apply absolute positioning to account for textarea resize, etc.
div.style.position = 'absolute'
// create a marker element to obtain caret position
const span = document.createElement('span')
// give the span the textContent of remaining content so that the recreated dummy element is as close as possible
span.textContent = inputValue.substr(selectionPoint) || '.'
// append the span marker to the div
div.appendChild(span)
// append the dummy element to the body
document.body.appendChild(div)
// get the marker position, this is the caret position top and left relative to the input
const { offsetLeft: spanX, offsetTop: spanY } = span
// lastly, remove that dummy element
// NOTE:: can comment this out for debugging purposes if you want to see where that span is rendered
document.body.removeChild(div)
// return an object with the x and y of the caret. account for input positioning so that you don't need to wrap the input
let x = left + spanX
let y = top + spanY
const { lineHeight, paddingRight } = copyStyle
x = Math.min(x - scrollLeft, left + width - parseInt(paddingRight, 10))
// Need to account for any scroll position for the window.
y = Math.min(y - scrollTop, top + height - parseInt(lineHeight, 10)) + window.scrollY
return {
x,
y,
}
}
const getSelectionPosition = (input) => {
const { y: startY, x: startX } = getCaretPosition(input, 'selectionStart')
const { x: endX } = getCaretPosition(input, 'selectionEnd')
// Gives you a basic left position for where to put it and the starting position.
const x = startX + ((endX - startX) / 2)
const y = startY
return {
x, y
}
}
const useCaretPosition = (element) => {
const [x, setX] = useState(null)
const [y, setY] = useState(null)
const getPosition = (element) => {
if (element.current) {
const { x, y } = getCaretPosition(element.current)
setX(x)
setY(y)
}
}
const getSelection = (element) => {
if (element.current) {
const { x, y } = getSelectionPosition(element.current)
setX(x)
setY(y)
}
}
return { x, y, getPosition, getSelection }
}
export default useCaretPosition

@ -1,9 +1,11 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import Markdown from 'markdown-to-jsx';
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css';
import { chakra,PropsOf} from '@chakra-ui/react';
import WebsiteLink from 'components/website-link';
import { find, findIndex } from 'lodash';
import { isUsernameChar } from 'utils/user';
type Props = PropsOf<typeof chakra.div> & {
@ -16,17 +18,43 @@ const ChakraMarkdown = chakra(Markdown)
export function MarkdownRender({ md,fontSize, ...rest }:Props) {
const rootRef = useRef<HTMLDivElement>();
const [renderMd,setRenderMd] = useState(md)
useEffect(() => {
rootRef.current.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
});
// deal with @username feature
const indexes: number[] = []
for (var i=0;i<md.length;i++) {
if (md[i] === '@') {
indexes.push(i)
}
}
for (const index of indexes) {
let nickname: string = ""
let username:string = ""
for (var i=index+1;i<md.length;i++) {
if (isUsernameChar(md[i])) {
username += md[i]
} else {
break
}
}
if (username !== '') {
setRenderMd(md.replace('@' + username, `[@${username}](/${username})`))
console.log(username)
}
}
console.log(indexes)
}, [md]);
return (
<div ref={rootRef} style={{height:'100%'}}>
<ChakraMarkdown
children={md}
children={renderMd}
{...rest}
style={{height:'100%',fontSize: fontSize??'16px'}}
className="markdown-render"

@ -1,4 +1,12 @@
import {User} from 'src/types/session'
export function getUserName(user:User) {
return user.nickname === "" ? user.username : user.nickname
}
export function isUsernameChar(c) {
if ((c >= "a" && "c<=z") || (c >= "0" && c <= "9") || (c === "-")) {
return true
}
return false
}

@ -0,0 +1,51 @@
import { Global, css } from "@emotion/react"
const CaretStyles = () => (
<Global
styles={(theme: any) => css`
.repo-link {
position: fixed;
top: 1rem;
right: 1rem;
height: 44px;
width: 44px;
}
.repo-link path {
fill: hsl(0, 0%, 10%);
cursor: pointer;
transition: fill 0.1s;
}
.repo-link:hover path {
fill: hsl(0, 0%, 40%);
}
.marker {
display: none;
position: absolute;
left: calc(var(--x, 0) * 1px);
top: calc(var(--y, 0) * 1px);
background: hsl(0, 0%, 10%);
color: hsl(0, 0%, 98%);
z-index: 9999;
padding: 6px;
border-radius: 4px;
transform: translate(10px, -25%);
transition: top 0.1s, left 0.1s;
}
.marker--selection {
transform: translate(-50%, -120%);
}
/* For debugging purposes */
textarea:focus ~ .marker--basic,
textarea:focus ~ .marker--selection {
display: block;
}
`}
/>
)
export default CaretStyles

@ -56,6 +56,9 @@ export default function markdownRender(props) {
},
pre: {
margin: '1.6rem 0'
},
a: {
textDecoration: 'underline !important'
}
}
}

@ -1,28 +1,45 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"hooks/*": ["src/hooks/*"],
"components/*": ["src/components/*"],
"utils/*": ["src/utils/*"],
"analytics/*": ["src/analytics/*"]
},
"downlevelIteration": true
"compilerOptions": {
"target": "es6",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"hooks/*": [
"src/hooks/*"
],
"components/*": [
"src/components/*"
],
"utils/*": [
"src/utils/*"
],
"analytics/*": [
"src/analytics/*"
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
"downlevelIteration": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

@ -0,0 +1 @@
Subproject commit 1b85f4a981c21a4e6823527302c6cb82b02f26bd

@ -3522,10 +3522,10 @@ react-is@16.13.1, react-is@^16.7.0, react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-markdown-editor-lite@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/react-markdown-editor-lite/-/react-markdown-editor-lite-1.2.4.tgz#d47ea00f62e7c82ae8803a990d011d4f44015c20"
integrity sha512-zUMDsrRxmjNeZEaVkL2oXlOpSJOmVtI5wwRv71MevyvwA/gZQOE7oJP6dSRe84HMccuoU2v1+FxNWKYJtMdPdw==
react-markdown-editor-lite-sunface@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/react-markdown-editor-lite-sunface/-/react-markdown-editor-lite-sunface-1.2.5.tgz#22684365e12884990da68aea2903e221de7ffd4c"
integrity sha512-fCQ0RCSZgni5ky43DbZqL6pSlDR7dMkSekTHG5GLRqiOk1jiIsRvvpVjdSMw3HBaworCFRrYSR7oN6WYRg3Dkg==
dependencies:
classnames "^2.2.6"

Loading…
Cancel
Save