Skip to content

Commit 60d82f7

Browse files
authored
Merge pull request #168 from channel-io/exp
v0.2.15 후속
2 parents a298418 + 9253323 commit 60d82f7

39 files changed

+5436
-622
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
src/components/Icon/generated/*.tsx
2+
src/components/Editor/utils/Parsers/Antlr/*.js

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ module.exports = {
77
'no-restricted-imports': 'off',
88
'no-restricted-modules': 'off',
99
'react/jsx-props-no-spreading': 'off',
10+
'max-classes-per-file': 'off',
1011
},
1112
}

.storybook/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ module.exports = {
2727

2828
config.resolve.extensions.push('.ts', '.tsx')
2929

30+
config.node = { fs: 'empty' }
31+
3032
return config
3133
}
3234
}

package-lock.json

Lines changed: 1383 additions & 613 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
"build": "cross-env BABEL_ENV=build rollup -c",
2828
"prebuild": "npm run typecheck",
2929
"prepublishOnly": "npm run build:icon && npm run build",
30+
"antlr:lexer": "antlr4ts src/components/Editor/utils/Parsers/Antlr/TextBlockLexer.g4",
31+
"antlr:parser": "antlr4ts src/components/Editor/utils/Parsers/Antlr/TextBlockParser.g4",
3032
"build:storybook": "build-storybook",
3133
"build:icon": "./scripts/build-icon.sh",
3234
"deploy:storybook": "storybook-to-ghpages --remote=upstream"
@@ -71,19 +73,26 @@
7173
"@rollup/plugin-babel": "^5.1.0",
7274
"@rollup/plugin-commonjs": "^11.0.2",
7375
"@rollup/plugin-node-resolve": "^7.1.3",
74-
"@storybook/addon-actions": "^6.0.20",
75-
"@storybook/addon-controls": "^6.0.20",
76-
"@storybook/addon-docs": "^6.0.20",
77-
"@storybook/addon-toolbars": "^6.0.20",
78-
"@storybook/react": "^6.0.20",
79-
"@storybook/storybook-deployer": "^2.8.6",
76+
"@storybook/addon-actions": "^6.0.26",
77+
"@storybook/addon-controls": "^6.0.26",
78+
"@storybook/addon-docs": "^6.0.26",
79+
"@storybook/addon-toolbars": "^6.0.26",
80+
"@storybook/react": "^6.0.26",
81+
"@storybook/storybook-deployer": "^2.8.7",
8082
"@svgr/babel-plugin-add-jsx-attribute": "^5.4.0",
8183
"@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0",
8284
"@svgr/cli": "^5.4.0",
8385
"@testing-library/jest-dom": "^5.5.0",
8486
"@testing-library/react": "^10.0.4",
87+
"@types/antlr4": "^4.7.2",
8588
"@types/jest": "^25.2.1",
8689
"@types/lodash-es": "^4.17.3",
90+
"@types/prosemirror-commands": "^1.0.3",
91+
"@types/prosemirror-inputrules": "^1.0.3",
92+
"@types/prosemirror-keymap": "^1.0.3",
93+
"@types/prosemirror-model": "^1.11.0",
94+
"@types/prosemirror-state": "^1.2.5",
95+
"@types/prosemirror-view": "^1.16.1",
8796
"@types/react": "^16.9.34",
8897
"@types/styled-components": "^5.1.0",
8998
"@types/uuid": "^8.3.0",
@@ -125,7 +134,15 @@
125134
"styled-components": ">=5"
126135
},
127136
"dependencies": {
137+
"antlr4": "^4.8.0",
138+
"core-decorators": "^0.20.0",
128139
"lodash-es": "^4.17.15",
140+
"prosemirror-commands": "^1.1.4",
141+
"prosemirror-inputrules": "^1.1.3",
142+
"prosemirror-keymap": "^1.1.4",
143+
"prosemirror-model": "^1.12.0",
144+
"prosemirror-state": "^1.3.3",
145+
"prosemirror-view": "^1.16.0",
129146
"uuid": "^8.3.1"
130147
}
131148
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/* External dependencies */
2+
import React, { useRef, useCallback } from 'react'
3+
import base from 'paths.macro'
4+
5+
/* Internal dependencies */
6+
import { getTitle } from '../../utils/utils'
7+
import BlocksParserContextType from '../../types/BlocksParserContextType'
8+
import Bold from './plugins/Bold'
9+
import { EditorRef } from './Editor.types'
10+
import { Editor, Parser, BlocksParserContext } from './index'
11+
12+
export default {
13+
title: getTitle(base),
14+
component: Editor,
15+
argTypes: {
16+
onChange: { action: 'onChange' },
17+
},
18+
}
19+
20+
const Template = (args) => {
21+
const editorRef = useRef<EditorRef>(null)
22+
23+
// eslint-disable-next-line no-console
24+
const handleGet = useCallback(() => console.log(editorRef.current.getContents()), [editorRef])
25+
const handleClear = useCallback(() => editorRef.current.clearContents(), [editorRef])
26+
27+
return (
28+
<>
29+
<Editor {...args} ref={editorRef}/>
30+
<button onClick={handleGet} type="button">
31+
get message
32+
</button>
33+
<button onClick={handleClear} type="button">
34+
clear message
35+
</button>
36+
</>
37+
)
38+
}
39+
40+
export const Primary = Template.bind({})
41+
Primary.args = {
42+
initialBlocks: [{ type: 'text', value: 'hello, world!' }],
43+
}
44+
45+
const TemplateWithBold = (args) => (
46+
<Editor {...args}>
47+
<Bold/>
48+
</Editor>
49+
)
50+
51+
export const PrimaryWithBold = TemplateWithBold.bind({})
52+
53+
function NodeReplacer(context: BlocksParserContext) {
54+
const mapChildReplace = (childContext: BlocksParserContext) =>
55+
childContext.children.map(child => NodeReplacer(child))
56+
57+
const { type } = context
58+
59+
switch (type) {
60+
case BlocksParserContextType.Root: {
61+
return (
62+
<>
63+
{ mapChildReplace(context) }
64+
</>
65+
)
66+
}
67+
68+
case BlocksParserContextType.Text: {
69+
return (
70+
<span>
71+
{ context.value }
72+
</span>
73+
)
74+
}
75+
76+
case BlocksParserContextType.Bold: {
77+
return (
78+
<b>
79+
{ mapChildReplace(context) }
80+
</b>
81+
)
82+
}
83+
84+
default: {
85+
return null
86+
}
87+
}
88+
}
89+
90+
const NodeReplacerTemplate = (args) => (NodeReplacer(Parser.parseBlockDefault(args.blocks[0].value).context))
91+
export const PrimaryReplacer = NodeReplacerTemplate.bind({})
92+
PrimaryReplacer.args = {
93+
blocks: [
94+
{
95+
type: 'text',
96+
value: 'hello <b>helelelelo</b> hafl',
97+
},
98+
],
99+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/* Internal dependencies */
2+
import { styled } from '../../styling/Theme'
3+
4+
export const StyledEditorInput = styled.div`
5+
min-width: 300px;
6+
min-height: 70px;
7+
border: 1px solid black;
8+
`

src/components/Editor/Editor.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/* External dependencies */
2+
import React, {
3+
createContext,
4+
forwardRef,
5+
useMemo,
6+
useEffect,
7+
useRef,
8+
useCallback,
9+
useImperativeHandle,
10+
} from 'react'
11+
import { DOMParser, ResolvedPos } from 'prosemirror-model'
12+
import { isEmpty, noop } from 'lodash-es'
13+
import 'prosemirror-view/style/prosemirror.css'
14+
15+
/* Internal dependencies */
16+
import useMergeRefs from '../../hooks/useMergeRefs'
17+
import EditorBuilder from './utils/EditorBuilder'
18+
import { blocksToPMNodes, pmNodesToBlocks } from './utils/Parsers'
19+
import { ReactNodeViewProvider } from './utils/ReactNodeView'
20+
import isFill from './utils/isFill'
21+
import { EditorProps, EditorRef } from './Editor.types'
22+
import { StyledEditorInput } from './Editor.styled'
23+
24+
export const BuilderContext = createContext(new EditorBuilder())
25+
26+
const emptyBlocks = []
27+
28+
function Editor(
29+
{
30+
initialBlocks = emptyBlocks,
31+
noStringEscape = false,
32+
onChange = noop,
33+
onClear = noop,
34+
children,
35+
}: EditorProps,
36+
forwardedRef: React.Ref<EditorRef>,
37+
) {
38+
const reactNodeViewProvider = useMemo(() => new ReactNodeViewProvider(), [])
39+
const editorBuilder: EditorBuilder = useMemo(() => new EditorBuilder(), [])
40+
41+
const editorRef = useRef<HTMLDivElement | null>(null)
42+
const mergedRef = useMergeRefs<HTMLDivElement | EditorRef>(editorRef, forwardedRef)
43+
44+
const getContents = useCallback(() => {
45+
const editorState = editorBuilder.editor?.state
46+
if (!editorState || !editorState.doc) {
47+
return undefined
48+
}
49+
50+
const blocks = isFill(editorState) ? pmNodesToBlocks(editorState.doc, noStringEscape) : []
51+
52+
return blocks
53+
}, [
54+
editorBuilder,
55+
noStringEscape,
56+
])
57+
58+
const clearContents = useCallback(() => {
59+
editorBuilder.reset()
60+
onClear()
61+
}, [
62+
editorBuilder,
63+
onClear,
64+
])
65+
66+
useImperativeHandle(forwardedRef, () => ({
67+
getContents,
68+
clearContents,
69+
}), [
70+
getContents,
71+
clearContents,
72+
])
73+
74+
useEffect(() => {
75+
const schema = editorBuilder.createSchema()
76+
const doc = (() => {
77+
if (initialBlocks) {
78+
const content = blocksToPMNodes(initialBlocks, schema)
79+
if (content.length > 0) {
80+
return schema.node('doc', undefined, content)
81+
}
82+
}
83+
84+
return undefined
85+
})()
86+
87+
editorBuilder.build(editorRef.current!, reactNodeViewProvider, schema, {
88+
dispatchTransaction: (transaction) => {
89+
const prevState = editorBuilder.editor!.state
90+
const newState = editorBuilder.editor!.state.apply(transaction)
91+
editorBuilder.updateState(newState)
92+
93+
// selection 만 바뀌는 경우에도 transaction 이 발생하지만, 이 경우에는 onChange 는 불필요하므로
94+
// doc 이 분명하게 바꾼 경우에만 onChange 발생
95+
if (prevState.doc !== newState.doc) {
96+
onChange(newState)
97+
}
98+
},
99+
handleDOMEvents: {
100+
// blur: () => {
101+
// onBlurRef.current()
102+
// return false
103+
// },
104+
// focus: () => {
105+
// onFocusRef.current()
106+
// return false
107+
// },
108+
},
109+
clipboardTextParser: (pastedText: string, context: ResolvedPos) => {
110+
/**
111+
* ClipBoard 로 부터 Text 를 파싱할 경우,
112+
* ProseMirror 기본 동작은 newline 을 모두 무시함.
113+
* 따라서 newline 을 지켜주는 parser 를 새로 구성함.
114+
* 참조: https://prosemirror.net/docs/ref/#view.EditorProps.clipboardTextParser
115+
*/
116+
const dom = window.document.createElement('div')
117+
118+
pastedText
119+
.trim()
120+
.split('\n')
121+
.forEach(block => {
122+
if (isEmpty(block)) {
123+
dom.appendChild(document.createElement('p'))
124+
} else {
125+
dom.appendChild(document.createElement('p')).textContent = block
126+
}
127+
})
128+
129+
return DOMParser
130+
.fromSchema(editorBuilder.editor!.state.schema)
131+
.parseSlice(dom, {
132+
preserveWhitespace: true,
133+
context,
134+
})
135+
},
136+
// clipboardTextSerializer: nodeToText,
137+
}, doc)
138+
139+
return () => {
140+
reactNodeViewProvider.clear()
141+
}
142+
// eslint-disable-next-line react-hooks/exhaustive-deps
143+
}, [])
144+
145+
return (
146+
<BuilderContext.Provider value={editorBuilder}>
147+
<StyledEditorInput ref={mergedRef as React.Ref<HTMLDivElement>}/>
148+
{ children }
149+
</BuilderContext.Provider>
150+
)
151+
}
152+
153+
export default forwardRef(Editor)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/* External dependencies */
2+
import { EditorState } from 'prosemirror-state'
3+
4+
/* Internal dependencies */
5+
import { ChildrenComponentProps } from '../../types/ComponentProps'
6+
7+
enum NodeType {
8+
Paragraph = 'paragraph',
9+
CodeBlock = 'codeBlock',
10+
BulletList = 'bulletList',
11+
ListItem = 'listItem',
12+
Text = 'text',
13+
Variable = 'variable',
14+
Emoji = 'emoji',
15+
Mention = 'mention',
16+
}
17+
18+
enum BlockType {
19+
Text = 'text',
20+
Code = 'code',
21+
Bullets = 'bullets',
22+
}
23+
24+
export {
25+
NodeType,
26+
BlockType,
27+
}
28+
29+
export interface Text {
30+
type: BlockType.Text
31+
value: string
32+
}
33+
34+
export interface Code {
35+
type: BlockType.Code
36+
language?: string
37+
value: string
38+
}
39+
40+
export interface Bullets {
41+
type: BlockType.Bullets
42+
blocks: Text[]
43+
}
44+
45+
export type Block = Text | Code | Bullets
46+
47+
export interface EditorProps extends ChildrenComponentProps {
48+
initialBlocks?: Block[]
49+
noStringEscape?: boolean
50+
onChange?: (state: EditorState) => void
51+
onClear?: () => void
52+
}
53+
54+
export interface EditorRef {
55+
getContents: () => Block[] | undefined
56+
clearContents: () => void
57+
}

0 commit comments

Comments
 (0)