diff --git a/.gitignore b/.gitignore index 4ed777102d..af3be3db91 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ yarn.lock # exclude sources.list sources.list +*.rdb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec49fef3c..cf8ed85fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2019-04-02 + +### Added + +- Editor: Embed - YouTube, Vimeo and JSFiddle #166 +- Editor: @user #169 +- Editor: SmartBreak #168 #170 +- About Page #180 +- OpenSearch #176 + +# Changed + +- Fix notice dropdown positioning issue with max-height #167 + ## [1.1.1] - 2019-03-28 ### Added diff --git a/common/enums/keyCodes.ts b/common/enums/keyCodes.ts index c579e5e454..0ba31c49d2 100644 --- a/common/enums/keyCodes.ts +++ b/common/enums/keyCodes.ts @@ -1,3 +1,8 @@ export const KEYCODES = { - escape: 27 + enter: 13, + escape: 27, + tab: 9, + up: 38, + down: 40, + v: 86 } diff --git a/common/styles/layouts/grids.css b/common/styles/layouts/grids.css index f0fa19e8df..b4633a1f12 100644 --- a/common/styles/layouts/grids.css +++ b/common/styles/layouts/grids.css @@ -92,3 +92,23 @@ } } } + +/* + * Waffle + */ +@each $i in 1, 2, 3, 4 { + .l-waffle-$(i) { + /* prettier-ignore */ + lost-column: 1/$(i); + } +} +@each $device in sm, md, lg, xl { + @media (--$(device)-up) { + @each $i in 1, 2, 3, 4 { + .l-waffle-$(device)-$(i) { + /* prettier-ignore */ + lost-column: 1/$(i); + } + } + } +} diff --git a/common/styles/mixins/mixins.css b/common/styles/mixins/mixins.css index d18a78ce9b..65d2f36155 100644 --- a/common/styles/mixins/mixins.css +++ b/common/styles/mixins/mixins.css @@ -36,6 +36,14 @@ justify-content: space-between; } +@define-mixin font-serif { + font-family: var(--font-serif-tc); + + &[lang='zh-hans'] { + font-family: var(--font-serif-sc); + } +} + @define-mixin border-bottom-grey { border-bottom: 1px solid var(--color-line-grey); /* fallback */ border-bottom: 0.5px solid var(--color-line-grey); diff --git a/common/styles/utils/content.article.css b/common/styles/utils/content.article.css index 796ead41bd..5d12033225 100644 --- a/common/styles/utils/content.article.css +++ b/common/styles/utils/content.article.css @@ -7,6 +7,9 @@ & :global(> * + *) { margin: var(--spacing-default) 0; } + & :global(> *:first-child) { + margin-top: 0; + } & :global(> *:last-child) { margin-bottom: var(--spacing-default); } @@ -43,6 +46,26 @@ } } + /* Embed + ========================================================================== */ + & :global(figure[class*='embed']) { + position: relative; + width: 100%; + height: 0; + padding-top: 56.25%; + background: var(--color-grey-lighter); + + & iframe { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + } + } + /* Blockquote ========================================================================== */ & :global(blockquote) { @@ -107,24 +130,17 @@ line-height: var(--line-height-article-heading); } - /* Paragraph - ========================================================================== */ - & :global(> p) { - text-align: justify; - text-justify: inter-ideograph; - } - /* Link ========================================================================== */ & :global(a) { - color: var(--color-matters-gold); + color: var(--color-matters-green); border-bottom: 1px solid currentcolor; padding-bottom: 2px; &:hover, &:active, &:focus { - color: var(--color-matters-gold-hover); + color: var(--color-matters-green-hover); } } /* fix frequent misuse of links */ @@ -147,3 +163,9 @@ border-bottom: 1px solid var(--color-line-grey-dark); } } + +.ql-editor { + & input.embed-clipboard { + line-height: 1.875; + } +} diff --git a/common/styles/utils/content.comment.css b/common/styles/utils/content.comment.css index e00977f953..38f8f14459 100644 --- a/common/styles/utils/content.comment.css +++ b/common/styles/utils/content.comment.css @@ -102,24 +102,17 @@ line-height: var(--line-height-article-heading); } - /* Paragraph - ========================================================================== */ - & :global(> p) { - text-align: justify; - text-justify: inter-ideograph; - } - /* Link ========================================================================== */ & :global(a) { - color: var(--color-matters-gold); + color: var(--color-matters-green); border-bottom: 1px solid currentcolor; padding-bottom: 2px; &:hover, &:active, &:focus { - color: var(--color-matters-gold-hover); + color: var(--color-matters-green-hover); } } /* fix frequent misuse of links */ diff --git a/common/styles/variables/shadows.css b/common/styles/variables/shadows.css new file mode 100644 index 0000000000..0ac8c54fbb --- /dev/null +++ b/common/styles/variables/shadows.css @@ -0,0 +1,9 @@ +/* @styled-jsx=global */ + +:root { + --shadow-light: 0 1px 4px 0 rgba(0, 0, 0, 0.08); + --shadow-default: 0 4px 12px 0 rgba(0, 0, 0, 0.05), + 0 1px 4px 0 rgba(0, 0, 0, 0.08); + --shadow-dark: 0 12px 24px 0 rgba(0, 0, 0, 0.12), + 0 4px 12px 0 rgba(0, 0, 0, 0.05); +} diff --git a/common/styles/vendors/quill.bubble.css b/common/styles/vendors/quill.bubble.css index 82e30cc2b0..292bca59fc 100644 --- a/common/styles/vendors/quill.bubble.css +++ b/common/styles/vendors/quill.bubble.css @@ -64,8 +64,7 @@ .ql-bubble .ql-toolbar { background: var(--background-color); border: solid 1px var(--border-color); - box-shadow: 0 12px 24px 0 var(--border-color), - 0 4px 12px 0 rgba(0, 0, 0, 0.04), 0 1px 4px 0 var(--border-color); + box-shadow: var(--shadow-dark); border-radius: var(--spacing-xx-tight); &:after { diff --git a/common/styles/vendors/tippy.css b/common/styles/vendors/tippy.css index c044540c72..8bc10715d6 100644 --- a/common/styles/vendors/tippy.css +++ b/common/styles/vendors/tippy.css @@ -1,13 +1,5 @@ /* @styled-jsx=global */ -:root { - --shadow-light: 0 1px 4px 0 rgba(0, 0, 0, 0.08); - --shadow-default: 0 4px 12px 0 rgba(0, 0, 0, 0.05), - 0 1px 4px 0 rgba(0, 0, 0, 0.08); - --shadow-dark: 0 12px 24px 0 rgba(0, 0, 0, 0.12), - 0 4px 12px 0 rgba(0, 0, 0, 0.05); -} - /** * reset * https://github.com/atomiks/tippyjs/blob/master/src/scss/index.scss diff --git a/common/utils/dom.ts b/common/utils/dom.ts index ae7adce69d..a57e38b373 100644 --- a/common/utils/dom.ts +++ b/common/utils/dom.ts @@ -41,11 +41,23 @@ const copyToClipboard = (str: string) => { document.body.removeChild(el) } +const getAttributes = (name: string, str: string): string[] | [] => { + const re = new RegExp(`${name}="(.*?)"`, 'g') + const matches = [] + let match = re.exec(str) + while (match) { + matches.push(match[1]) + match = re.exec(str) + } + return matches.filter(m => !!m) +} + export const dom = { $, $$, getWindowHeight, getWindowWidth, offset, - copyToClipboard + copyToClipboard, + getAttributes } diff --git a/common/utils/validator.ts b/common/utils/validator.ts index ee93d04365..5ad5e969a8 100644 --- a/common/utils/validator.ts +++ b/common/utils/validator.ts @@ -35,6 +35,8 @@ export const isValidStrictPassword = (password: string): boolean => { * * @see https://mattersnews.slack.com/archives/G8877EQMS/p1546446430005500 */ +export const REGEXP_DISPLAY_NAME = /^[A-Za-z0-9\u4E00-\u9FFF\u3400-\u4DFF\uF900-\uFAFF\u2e80-\u33ffh]*$/ + export const isValidDisplayName = (name: string): boolean => { if ( !name || @@ -43,9 +45,7 @@ export const isValidDisplayName = (name: string): boolean => { ) { return false } - return /^[A-Za-z0-9\u4E00-\u9FFF\u3400-\u4DFF\uF900-\uFAFF\u2e80-\u33ffh]*$/.test( - name - ) + return REGEXP_DISPLAY_NAME.test(name) } /** diff --git a/components/ArticleDigest/FeatureDigest/styles.css b/components/ArticleDigest/FeatureDigest/styles.css index 2b9694fe8a..a2bdd2c432 100644 --- a/components/ArticleDigest/FeatureDigest/styles.css +++ b/components/ArticleDigest/FeatureDigest/styles.css @@ -19,7 +19,7 @@ .content-container { position: relative; - margin: -8rem 0 var(--spacing-x-loose) 0; + margin: -7.5rem 0 var(--spacing-x-loose) 0; padding: 0; background: var(--color-white); diff --git a/components/CommentDigest/FooterActions/UpvoteButton.tsx b/components/CommentDigest/FooterActions/UpvoteButton.tsx index 0012c1c5b5..581f0dfc6e 100644 --- a/components/CommentDigest/FooterActions/UpvoteButton.tsx +++ b/components/CommentDigest/FooterActions/UpvoteButton.tsx @@ -101,7 +101,7 @@ const UpvoteButton = ({ optimisticResponse={{ voteComment: { id: comment.id, - myVote: null, + myVote: 'up', __typename: 'Comment' } }} diff --git a/components/Dropdown/UserList/index.tsx b/components/Dropdown/UserList/index.tsx index c92352c58d..456a2353a1 100644 --- a/components/Dropdown/UserList/index.tsx +++ b/components/Dropdown/UserList/index.tsx @@ -8,12 +8,10 @@ import styles from './styles.css' const DropdownUserList = ({ users, onClick, - hideDropdown, loading }: { users: UserDigestBriefDescUser[] onClick: (user: UserDigestBriefDescUser) => void - hideDropdown: () => void loading?: boolean }) => { if (loading) { @@ -40,7 +38,6 @@ const DropdownUserList = ({ type="button" onClick={() => { onClick(user) - hideDropdown() }} > diff --git a/components/Editor/CommentEditor/config.ts b/components/Editor/CommentEditor/config.ts deleted file mode 100644 index 5289d6a429..0000000000 --- a/components/Editor/CommentEditor/config.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const modules = { - toolbar: [ - ['bold', 'italic', 'underline'], - ['blockquote', { list: 'ordered' }, { list: 'bullet' }, 'link'] - ], - clipboard: { - // toggle to add extra line breaks when pasting HTML: - matchVisual: false - } -} - -export const formats = [ - 'header', - 'font', - 'size', - 'bold', - 'italic', - 'underline', - 'strike', - 'blockquote', - 'list', - 'bullet', - 'indent', - 'link' -] diff --git a/components/Editor/CommentEditor/index.tsx b/components/Editor/CommentEditor/index.tsx index c6bbc40e55..6c75e2192b 100644 --- a/components/Editor/CommentEditor/index.tsx +++ b/components/Editor/CommentEditor/index.tsx @@ -1,15 +1,25 @@ import classNames from 'classnames' import _debounce from 'lodash/debounce' +import _get from 'lodash/get' import React from 'react' +import { QueryResult } from 'react-apollo' import ReactQuill, { Quill } from 'react-quill' +import UserList from '~/components/Dropdown/UserList' +import { Query } from '~/components/GQL' +import { + SearchUsers, + SearchUsers_search_edges_node_User +} from '~/components/GQL/queries/__generated__/SearchUsers' +import SEARCH_USERS from '~/components/GQL/queries/searchUsers' import { LanguageConsumer } from '~/components/Language' +import { Spinner } from '~/components/Spinner' import contentStyles from '~/common/styles/utils/content.comment.css' import bubbleStyles from '~/common/styles/vendors/quill.bubble.css' import { translate } from '~/common/utils' -import * as config from './config' +import * as config from '../configs/comment' import styles from './styles.css' interface Props { @@ -21,23 +31,31 @@ interface Props { interface State { focus: boolean + search: string + mentionInstance: any } class CommentEditor extends React.Component { private quill: Quill | null = null private reactQuillRef = React.createRef() + private mentionContainerRef = React.createRef() constructor(props: Props) { super(props) - this.state = { focus: false } + this.state = { focus: false, search: '', mentionInstance: null } } - public componentDidMount() { + componentDidMount() { this.attachQuillRefs() this.resetLinkInputPlaceholder() } - public attachQuillRefs = () => { + componentDidUpdate() { + this.attachQuillRefs() + this.resetLinkInputPlaceholder() + } + + attachQuillRefs = () => { if ( !this.reactQuillRef || !this.reactQuillRef.current || @@ -51,7 +69,7 @@ class CommentEditor extends React.Component { /** * https://github.com/quilljs/quill/issues/1107#issuecomment-259938173 */ - public resetLinkInputPlaceholder = () => { + resetLinkInputPlaceholder = () => { if (!this.quill) { return } @@ -72,42 +90,89 @@ class CommentEditor extends React.Component { } } - public render() { + onMentionChange = (search: string) => { + this.setState({ search }) + } + + onMentionModuleInit = (instance: any) => { + this.setState({ mentionInstance: instance }) + } + + render() { + const { focus, search, mentionInstance } = this.state const { content, handleChange, lang } = this.props const containerClasses = classNames({ container: true, - focus: this.state.focus + focus }) return ( - <> -
- this.setState({ focus: true })} - onBlur={() => this.setState({ focus: false })} - bounds="#comment-editor" - /> -
- - - - - + + {({ data, loading }: QueryResult & { data: SearchUsers }) => { + const users = _get(data, 'search.edges', []).map( + ({ node }: { node: SearchUsers_search_edges_node_User }) => node + ) + console.log('content', content) + return ( + <> +
+ this.setState({ focus: true })} + onBlur={() => this.setState({ focus: false })} + bounds="#comment-editor" + /> + + +
+ + + + + + ) + }} +
) } } diff --git a/components/Editor/CommentEditor/styles.css b/components/Editor/CommentEditor/styles.css index 8eaa9054b8..8b5c485615 100644 --- a/components/Editor/CommentEditor/styles.css +++ b/components/Editor/CommentEditor/styles.css @@ -1,6 +1,7 @@ .container { @mixin all-transition; + position: relative; padding: var(--spacing-x-tight) var(--spacing-tight); font-size: var(--font-size-sm); /* fallbakc */ @@ -21,3 +22,16 @@ min-height: 4rem; } } + +.mention-container { + position: absolute; + + width: 20rem; + visibility: hidden; + + color: var(--color-black); + border-radius: 2px; + background: var(--color-white); + box-shadow: var(--shadow-dark); + border: 1px solid var(--color-line-grey-light); +} diff --git a/components/Editor/SideToolbar/EmbedCodeButton.tsx b/components/Editor/SideToolbar/EmbedCodeButton.tsx new file mode 100644 index 0000000000..39290dcfd3 --- /dev/null +++ b/components/Editor/SideToolbar/EmbedCodeButton.tsx @@ -0,0 +1,50 @@ +import { useContext } from 'react' +import { Quill } from 'react-quill' + +import { Icon } from '~/components/Icon' +import { LanguageContext } from '~/components/Language' + +import { translate } from '~/common/utils' +import ICON_EDITOR_CODE from '~/static/icons/editor-code.svg?sprite' + +interface Props { + quill: Quill | null + setExpanded: (expanded: boolean) => void +} + +const EmbedCodeButton = ({ quill, setExpanded }: Props) => { + const { lang } = useContext(LanguageContext) + + const placeholder = translate({ + zh_hant: '貼上 JSFiddle 連結後,Enter 進行新增', + zh_hans: '贴上 JSFiddle 链接後,Enter 进行新增', + lang + }) + + const hint = translate({ + zh_hant: '新增程式碼連結', + zh_hans: '新增代碼链接', + lang + }) + + const insertEmbedClipboard = () => { + if (quill) { + const data = { purpose: 'code', placeholder } + const range = quill.getSelection(true) + quill.insertEmbed(range.index, 'embedClipboard', data, 'user') + } + setExpanded(false) + } + + return ( + + ) +} + +export default EmbedCodeButton diff --git a/components/Editor/SideToolbar/EmbedVideoButton.tsx b/components/Editor/SideToolbar/EmbedVideoButton.tsx new file mode 100644 index 0000000000..f4d53be0d3 --- /dev/null +++ b/components/Editor/SideToolbar/EmbedVideoButton.tsx @@ -0,0 +1,50 @@ +import { useContext } from 'react' +import { Quill } from 'react-quill' + +import { Icon } from '~/components/Icon' +import { LanguageContext } from '~/components/Language' + +import { translate } from '~/common/utils' +import ICON_EDITOR_VIDEO from '~/static/icons/editor-video.svg?sprite' + +interface Props { + quill: Quill | null + setExpanded: (expanded: boolean) => void +} + +const EmbedVideoButton = ({ quill, setExpanded }: Props) => { + const { lang } = useContext(LanguageContext) + + const placeholder = translate({ + zh_hant: '貼上 YouTube、Vimeo 連結後,Enter 進行新增', + zh_hans: '贴上 YouTube、Vimeo 链接後,Enter 进行新增', + lang + }) + + const hint = translate({ + zh_hant: '新增影片', + zh_hans: '新增影片', + lang + }) + + const insertEmbedClipboard = () => { + if (quill) { + const data = { purpose: 'video', placeholder } + const range = quill.getSelection(true) + quill.insertEmbed(range.index, 'embedClipboard', data, 'user') + } + setExpanded(false) + } + + return ( + + ) +} + +export default EmbedVideoButton diff --git a/components/Editor/SideToolbar/index.tsx b/components/Editor/SideToolbar/index.tsx index 99f18ea12d..3ee2a7a149 100644 --- a/components/Editor/SideToolbar/index.tsx +++ b/components/Editor/SideToolbar/index.tsx @@ -7,6 +7,8 @@ import { Icon } from '~/components/Icon' import ICON_EDITOR_ADD from '~/static/icons/editor-add.svg?sprite' import DividerButton from './DividerButton' +import EmbedCodeButton from './EmbedCodeButton' +import EmbedVideoButton from './EmbedVideoButton' import styles from './styles.css' import UploadImageButton from './UploadImageButton' @@ -46,6 +48,8 @@ const SideToolbar = ({ show, top, quill, onSave }: SideToolbarProps) => { onSave={onSave} setExpanded={setExpanded} /> + + { + domNode.focus() + }) + } + + onBlur = (event: FocusEvent) => { + const target = event.currentTarget as HTMLInputElement + + if (!target.value) { + this.removeBlot() + } else { + this.submit(target.value) + } + } + + onPaste = (event: ClipboardEvent) => { + event.stopPropagation() + } + + onPress = (event: KeyboardEvent) => { + event.stopPropagation() + + const key = event.which || event.keyCode + const target = event.currentTarget as HTMLInputElement + + if (!target.value && key !== KEYCODES.enter) { + return + } + + // blur to trigger `this.onBlur` to fire `this.submit` + target.blur() + } + + removeBlot = () => { + this.remove() + + if (!this.quill) { + return + } + + const range = this.quill.getSelection(true) + this.quill.setSelection(range.index, 0, 'silent') + } + + submit = (text: string) => { + const { embedClipboard } = this.value() + let url = '' + + if (!this.quill) { + return + } + + if (embedClipboard.purpose === 'video') { + url = embedUrl.video(text) + } else if (embedClipboard.purpose === 'code') { + url = embedUrl.code(text) + } + + if (url) { + this.insertEmbed(url) + } else { + this.replaceWithText(text) + } + } + + insertEmbed = (url: string) => { + const { embedClipboard } = this.value() + const range = this.quill.getSelection(true) + const blotName = { + video: 'embedVideo', + code: 'embedCode' + }[embedClipboard.purpose as Purpose] + this.removeBlot() + this.quill.insertEmbed(range.index, blotName, url, 'user') + this.quill.setSelection(range.index + 1, 0, 'silent') + } + + replaceWithText = (text: string) => { + const range = this.quill.getSelection(true) + this.removeBlot() + this.quill.insertText(range.index, text, 'user') + this.quill.setSelection(range.index + text.length, 0, 'silent') + } +} + +EmbedClipboard.blotName = 'embedClipboard' +EmbedClipboard.className = 'embed-clipboard' +EmbedClipboard.tagName = 'input' + +Quill.register('formats/embedClipboard', EmbedClipboard) + +export default EmbedClipboard diff --git a/components/Editor/blots/EmbedCode.ts b/components/Editor/blots/EmbedCode.ts new file mode 100644 index 0000000000..9e18eb8ee0 --- /dev/null +++ b/components/Editor/blots/EmbedCode.ts @@ -0,0 +1,34 @@ +import { Quill } from 'react-quill' + +const BlockEmbed = Quill.import('blots/block/embed') + +class EmbedCode extends BlockEmbed { + static create(url: string) { + const node = super.create() + const iframe = document.createElement('iframe') + iframe.setAttribute('src', url) + iframe.setAttribute('frameborder', '0') + iframe.setAttribute('allowfullscreen', 'false') + iframe.setAttribute( + 'sandbox', + 'allow-scripts allow-same-origin allow-popups' + ) + + node.setAttribute('contenteditable', 'fasle') + node.appendChild(iframe) + return node + } + + static value(node: HTMLElement) { + const iframe = node.querySelector('iframe') + return iframe ? iframe.getAttribute('src') : null + } +} + +EmbedCode.blotName = 'embedCode' +EmbedCode.className = 'embed-code' +EmbedCode.tagName = 'figure' + +Quill.register('formats/embedCode', EmbedCode) + +export default EmbedCode diff --git a/components/Editor/blots/EmbedVideo.ts b/components/Editor/blots/EmbedVideo.ts new file mode 100644 index 0000000000..5f7d79adce --- /dev/null +++ b/components/Editor/blots/EmbedVideo.ts @@ -0,0 +1,34 @@ +import { Quill } from 'react-quill' + +const BlockEmbed = Quill.import('blots/block/embed') + +class EmbedVideo extends BlockEmbed { + static create(url: string) { + const node = super.create() + const iframe = document.createElement('iframe') + iframe.setAttribute('src', url) + iframe.setAttribute('frameborder', '0') + iframe.setAttribute('allowfullscreen', 'true') + iframe.setAttribute( + 'sandbox', + 'allow-scripts allow-same-origin allow-popups' + ) + + node.setAttribute('contenteditable', 'fasle') + node.appendChild(iframe) + return node + } + + static value(node: HTMLElement) { + const iframe = node.querySelector('iframe') + return iframe ? iframe.getAttribute('src') : null + } +} + +EmbedVideo.blotName = 'embedVideo' +EmbedVideo.className = 'embed-video' +EmbedVideo.tagName = 'figure' + +Quill.register('formats/embedVideo', EmbedVideo) + +export default EmbedVideo diff --git a/components/Editor/blots/GithubGist.ts b/components/Editor/blots/GithubGist.ts deleted file mode 100644 index a13015962c..0000000000 --- a/components/Editor/blots/GithubGist.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Quill } from 'react-quill' - -const BlockEmbed = Quill.import('blots/block/embed') -// CodeBlot -class GithubGistBlot extends BlockEmbed { - public static create(url: string) { - const node = super.create() - - const parent = document.getElementById('editor') - if (parent) { - let childLength = parent.querySelectorAll('.record').length - const iframe = document.getElementById('frame') as HTMLIFrameElement - - if (iframe) { - const iframeNew = iframe.cloneNode(true) as HTMLIFrameElement - childLength += 1 - - iframeNew.setAttribute('id', 'frame_' + childLength) - iframeNew.setAttribute('width', '100%') - iframeNew.setAttribute('height', '300px') - iframeNew.setAttribute( - 'style', - 'border-style: solid; border-color: #eee' - ) - - node.appendChild(iframeNew) - - setTimeout(() => { - if (typeof url !== 'string') { - return - } - - const frame = document.getElementById( - 'frame_' + childLength - ) as HTMLIFrameElement - - if (frame) { - const dom = document.all - ? frame.contentWindow && frame.contentWindow.document - : frame.contentDocument - - if (dom) { - dom.open() - if (frame.contentWindow) { - frame.contentWindow.document.write(` - - - - - - - - - - - - `) - } - - // ep: https://gist.github.com/sammieho1995/fac3bbb632d07551a9a51f1afc35b76e.js - dom.close() - // dom.contentEditable = true - dom.designMode = 'on' - frame.removeAttribute('hidden') - } - } - }, 0) - - return node - } - } - } - - public static value(node: any) { - return node - } -} -GithubGistBlot.blotName = 'gist' -GithubGistBlot.tagName = 'div' -GithubGistBlot.className = 'code-blot-wrapper' - -export default GithubGistBlot diff --git a/components/Editor/blots/ImageFigure.ts b/components/Editor/blots/ImageFigure.ts index 1e2ef1d392..c2db1a82bd 100644 --- a/components/Editor/blots/ImageFigure.ts +++ b/components/Editor/blots/ImageFigure.ts @@ -37,7 +37,9 @@ class ImageFigure extends BlockEmbed { } ImageFigure.blotName = 'imageFigure' -ImageFigure.tagName = 'figure' ImageFigure.className = 'image' +ImageFigure.tagName = 'figure' + +Quill.register('formats/imageFigure', ImageFigure) export default ImageFigure diff --git a/components/Editor/blots/Mention.ts b/components/Editor/blots/Mention.ts new file mode 100644 index 0000000000..fd331200c3 --- /dev/null +++ b/components/Editor/blots/Mention.ts @@ -0,0 +1,30 @@ +import { Quill } from 'react-quill' + +const Embed = Quill.import('blots/embed') + +class Mention extends Embed { + static create(value: { id: string; displayName: string; userName: string }) { + const node = super.create(value) as HTMLElement + + node.setAttribute('href', `/@${value.userName}`) + node.setAttribute('target', '_blank') + node.dataset.displayName = value.displayName + node.dataset.userName = value.userName + node.dataset.id = value.id + node.textContent = `@${value.displayName}` + + return node + } + + static value(domNode: HTMLElement) { + return domNode.dataset + } +} + +Mention.blotName = 'mention' +Mention.tagName = 'a' +Mention.className = 'mention' + +Quill.register('formats/mention', Mention) + +export default Mention diff --git a/components/Editor/blots/Pastebin.ts b/components/Editor/blots/Pastebin.ts deleted file mode 100644 index b184f528b2..0000000000 --- a/components/Editor/blots/Pastebin.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Quill } from 'react-quill' - -const BlockEmbed = Quill.import('blots/block/embed') -// PastebinBlot -class PastebinBlot extends BlockEmbed { - public static create(url: string) { - const node = super.create() - const clientWidth = document.body.clientWidth - node.setAttribute('src', url) - node.setAttribute('frameborder', '0') - node.setAttribute('border', 'none') - node.setAttribute('width', '100%') - node.setAttribute('height', clientWidth > 425 ? '500px' : '250px') - return node - } - - public static formats(node: HTMLElement) { - let format = {} - if (node.hasAttribute('height')) { - format = { height: node.getAttribute('height') } - } - if (node.hasAttribute('width')) { - format = { width: node.getAttribute('width'), ...format } - } - return format - } - - public static value(node: HTMLElement) { - return node.getAttribute('src') - } - - public format(name: string, value: string) { - if (name === 'height' || name === 'width') { - if (value) { - this.domNode.setAttribute(name, value) - } else { - this.domNode.removeAttribute(name, value) - } - } else { - super.format(name, value) - } - } -} -PastebinBlot.blotName = 'pastebin' -PastebinBlot.tagName = 'iframe' - -export default PastebinBlot diff --git a/components/Editor/blots/SmartBreak.ts b/components/Editor/blots/SmartBreak.ts new file mode 100644 index 0000000000..ebc40b445e --- /dev/null +++ b/components/Editor/blots/SmartBreak.ts @@ -0,0 +1,16 @@ +import { Quill } from 'react-quill' + +const Embed = Quill.import('blots/embed') + +/** + * @see {@url https://github.com/quilljs/quill/issues/252} + */ +class SmartBreak extends Embed {} + +SmartBreak.blotName = 'smartBreak' +SmartBreak.className = 'smart' +SmartBreak.tagName = 'br' + +Quill.register('formats/smartBreak', SmartBreak) + +export default SmartBreak diff --git a/components/Editor/blots/Video.ts b/components/Editor/blots/Video.ts deleted file mode 100644 index 0eef1918d3..0000000000 --- a/components/Editor/blots/Video.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Quill } from 'react-quill' - -const BlockEmbed = Quill.import('blots/block/embed') - -class VideoBlot extends BlockEmbed { - public static create(url: string) { - const node = super.create() - const clientWidth = document.body.clientWidth - node.setAttribute('src', url) - node.setAttribute('frameborder', '0') - node.setAttribute('allowfullscreen', true) - node.setAttribute('width', '100%') - node.setAttribute('height', clientWidth > 425 ? '500px' : '250px') - return node - } - - public static formats(node: HTMLElement) { - let format = {} - if (node.hasAttribute('height')) { - format = { height: node.getAttribute('height') } - } - if (node.hasAttribute('width')) { - format = { width: node.getAttribute('width'), ...format } - } - return format - } - - public static value(node: HTMLElement) { - return node.getAttribute('src') - } - - public format(name: string, value: any) { - if (name === 'height' || name === 'width') { - if (value) { - this.domNode.setAttribute(name, value) - } else { - this.domNode.removeAttribute(name, value) - } - } else { - super.format(name, value) - } - } -} -VideoBlot.blotName = 'video' -VideoBlot.tagName = 'iframe' - -export default VideoBlot diff --git a/components/Editor/blots/index.ts b/components/Editor/blots/index.ts index 3b5c8261a7..4b8594dbd3 100644 --- a/components/Editor/blots/index.ts +++ b/components/Editor/blots/index.ts @@ -1,15 +1,9 @@ -import { Quill } from 'react-quill' +import './Divider' +import './EmbedClipboard' +import './EmbedCode' +import './EmbedVideo' +import './ImageFigure' +import './Mention' +import './SmartBreak' -import DividerBlot from './Divider' -import ImageFigure from './ImageFigure' -// import GithubGistBlot from './GithubGist' -// import PastebinBlot from './Pastebin' -// import VideoBlot from './Video' - -export default { - DividerBlot, - register: () => { - Quill.register('formats/divider', DividerBlot) - Quill.register('formats/imageFigure', ImageFigure) - } -} +export default null diff --git a/components/Editor/config.ts b/components/Editor/config.ts deleted file mode 100644 index 506afaea66..0000000000 --- a/components/Editor/config.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const modules = { - toolbar: [ - [{ header: '2' }, 'bold', 'italic', 'strike', 'underline'], - ['blockquote', { list: 'ordered' }, { list: 'bullet' }, 'link'] - ], - clipboard: { - // toggle to add extra line breaks when pasting HTML: - matchVisual: false - } -} - -export const formats = [ - 'header', - 'font', - 'size', - 'bold', - 'italic', - 'underline', - 'strike', - 'blockquote', - 'list', - 'bullet', - 'indent', - 'link', - 'image', - 'video', - 'divider', - 'imageFigure' -] diff --git a/components/Editor/configs/comment.ts b/components/Editor/configs/comment.ts new file mode 100644 index 0000000000..6169e67cf5 --- /dev/null +++ b/components/Editor/configs/comment.ts @@ -0,0 +1,9 @@ +import * as config from './default' + +export const modules = { + ...config.modules, + toolbar: [ + ['bold', 'italic', 'underline'], + ['blockquote', { list: 'ordered' }, { list: 'bullet' }, 'link'] + ] +} diff --git a/components/Editor/configs/default.ts b/components/Editor/configs/default.ts new file mode 100644 index 0000000000..4cc7941efa --- /dev/null +++ b/components/Editor/configs/default.ts @@ -0,0 +1,101 @@ +import { Quill } from 'react-quill' + +import { KEYCODES } from '~/common/enums' + +import '../blots' +import '../modules/mention' +import lineBreakMatcher from '../utils/lineBreakMatcher' + +const Parchment = Quill.import('parchment') + +export const modules = { + toolbar: [ + [{ header: '2' }, 'bold', 'italic', 'strike', 'underline'], + ['blockquote', { list: 'ordered' }, { list: 'bullet' }, 'link'] + ], + clipboard: { + // toggle to add extra line breaks when pasting HTML: + matchVisual: false, + matchers: [['BR', lineBreakMatcher]] + }, + keyboard: { + bindings: { + tab: { + key: KEYCODES.tab, + handler() { + return false + } + }, + handleEnter: { + key: KEYCODES.enter, + handler(range: any, context: any) { + // @ts-ignore + const quill = this.quill + if (range.length > 0) { + // Remove characters in selected range + // So we do not trigger `text-change` + quill.scroll.deleteAt(range.index, range.length) + } + + const lineFormats = Object.keys(context.format).reduce( + (lf: any, format) => { + if ( + Parchment.query(format, Parchment.Scope.BLOCK) && + !Array.isArray(context.format[format]) + ) { + lf[format] = context.format[format] + } + return lf + }, + {} + ) as any + const previousChar = quill.getText(range.index - 1, 1) + + // Earlier scroll.deleteAt might have messed up our selection, + // so insertText's built in selection preservation is not reliable + quill.insertText(range.index, '\n', lineFormats, 'user') + + if (previousChar === '' || previousChar === '\n') { + quill.setSelection(range.index + 2, 'silent') + } else { + quill.setSelection(range.index + 1, 'silent') + } + + Object.keys(context.format).forEach(name => { + if (lineFormats[name] != null) { + return + } + if (Array.isArray(context.format[name])) { + return + } + if (name === 'link') { + return + } + quill.format(name, context.format[name], 'user') + }) + } + }, + linebreak: { + key: KEYCODES.enter, + shiftKey: true, + handler(range: any) { + // @ts-ignore + const quill = this.quill + const currentLeaf = quill.getLeaf(range.index)[0] + const nextLeaf = quill.getLeaf(range.index + 1)[0] + + quill.insertEmbed(range.index, 'smartBreak', true, 'user') + + // Insert a second break if: + // At the end of the editor, OR next leaf has a different parent (

) + if (nextLeaf === null || currentLeaf.parent !== nextLeaf.parent) { + quill.insertEmbed(range.index, 'smartBreak', true, 'user') + } + + // Now that we've inserted a line break, move the cursor forward + quill.setSelection(range.index + 1, 'silent') + } + } + } + } +} diff --git a/components/Editor/index.tsx b/components/Editor/index.tsx index 2d6d7509c0..9186529944 100644 --- a/components/Editor/index.tsx +++ b/components/Editor/index.tsx @@ -1,17 +1,27 @@ import classNames from 'classnames' import _debounce from 'lodash/debounce' +import _get from 'lodash/get' +import _includes from 'lodash/includes' import React from 'react' +import { QueryResult } from 'react-apollo' import ReactQuill, { Quill } from 'react-quill' +import UserList from '~/components/Dropdown/UserList' +import { Query } from '~/components/GQL' +import { + SearchUsers, + SearchUsers_search_edges_node_User +} from '~/components/GQL/queries/__generated__/SearchUsers' +import SEARCH_USERS from '~/components/GQL/queries/searchUsers' import { LanguageConsumer } from '~/components/Language' +import { Spinner } from '~/components/Spinner' import contentStyles from '~/common/styles/utils/content.article.css' import bubbleStyles from '~/common/styles/vendors/quill.bubble.css' import { translate } from '~/common/utils' import { EditorDraft } from './__generated__/EditorDraft' -import blots from './blots' -import * as config from './config' +import * as config from './configs/default' import SideToolbar from './SideToolbar' import styles from './styles.css' @@ -27,16 +37,14 @@ interface State { show: boolean top: number } + search: string + mentionInstance: any } -/** - * Register Custom Blots - */ -blots.register() - class Editor extends React.Component { private quill: Quill | null = null private reactQuillRef = React.createRef() + private mentionContainerRef = React.createRef() constructor(props: Props) { super(props) @@ -46,20 +54,22 @@ class Editor extends React.Component { sideToolbar: { show: false, top: 0 - } + }, + search: '', + mentionInstance: null } this.saveDraft = _debounce(this.saveDraft.bind(this), 3000) } - public componentDidMount() { + componentDidMount() { this.attachQuillRefs() this.resetLinkInputPlaceholder() } - public componentDidUpdate(prevProps: Props) { - // this.attachQuillRefs() - // this.resetLinkInputPlaceholder() + componentDidUpdate(prevProps: Props) { + this.attachQuillRefs() + this.resetLinkInputPlaceholder() if (prevProps.draft.id === this.props.draft.id) { return @@ -74,7 +84,7 @@ class Editor extends React.Component { }) } - public attachQuillRefs = () => { + attachQuillRefs = () => { if ( !this.reactQuillRef || !this.reactQuillRef.current || @@ -88,7 +98,7 @@ class Editor extends React.Component { /** * https://github.com/quilljs/quill/issues/1107#issuecomment-259938173 */ - public resetLinkInputPlaceholder = () => { + resetLinkInputPlaceholder = () => { if (!this.quill) { return } @@ -108,16 +118,16 @@ class Editor extends React.Component { } } - public saveDraft() { + saveDraft() { // TODO: skip if same content as before saved this.props.onSave({ content: this.state.content }) } - public handleChange = (content: string) => { + handleChange = (content: string) => { this.setState({ content }, this.saveDraft) } - public handleOnChangeSelection = ( + handleOnChangeSelection = ( range: { index: number length: number @@ -132,22 +142,42 @@ class Editor extends React.Component { const bounds = editor.getBounds(range) const nextChar = editor.getText(range.index, 1).replace(/\s/, '') const isNewLine = bounds.left === 0 && !nextChar + const [blot] = this.quill ? this.quill.getLeaf(range.index) : [null] // hide sideToolbar - if (!isNewLine && this.state.sideToolbar.show) { + if (this.isCustomBlot(blot)) { this.setState({ sideToolbar: { show: false, top: bounds.top || 0 } }) + } else if (!isNewLine && this.state.sideToolbar.show) { + this.setState({ + sideToolbar: { show: false, top: bounds.top || 0 } + }) + } else if (isNewLine) { + // show sideToolbar + this.setState({ sideToolbar: { show: true, top: bounds.top } }) } + } - // show sideToolbar - if (isNewLine) { - this.setState({ sideToolbar: { show: true, top: bounds.top } }) + public isCustomBlot(blot: any): boolean { + const types = ['embedClipboard'] + if (blot && blot.statics && _includes(types, blot.statics.blotName)) { + return true } + return false + } + + onMentionChange = (search: string) => { + this.setState({ search }) + } + + onMentionModuleInit = (instance: any) => { + this.setState({ mentionInstance: instance }) } - public render() { + render() { const { draft, onSave, lang } = this.props + const { search, mentionInstance } = this.state const isPending = draft.publishState === 'pending' const isPublished = draft.publishState === 'published' const containerClasses = classNames({ @@ -156,39 +186,77 @@ class Editor extends React.Component { }) return ( - <> -

- - -
- - - - - + + {({ data, loading }: QueryResult & { data: SearchUsers }) => { + const users = _get(data, 'search.edges', []).map( + ({ node }: { node: SearchUsers_search_edges_node_User }) => node + ) + + return ( + <> +
+ + + + +
+ + + + + + ) + }} +
) } } diff --git a/components/Editor/modules/mention.ts b/components/Editor/modules/mention.ts new file mode 100644 index 0000000000..93890fda71 --- /dev/null +++ b/components/Editor/modules/mention.ts @@ -0,0 +1,183 @@ +import { Quill } from 'react-quill' + +import { REGEXP_DISPLAY_NAME } from '~/common/utils' + +/** + * https://github.com/afconsult/quill-mention + */ +class Mention { + mentionDenotationChars: string[] + quill: Quill + mentionCharPos: any + cursorPos: number | null + maxChars: number + offsetTop: number + offsetLeft: number + isolateCharacter: boolean + + mentionContainer: HTMLElement + onMentionChange: (value: string) => void + + constructor(quill: Quill, options: any) { + this.mentionCharPos = null + this.cursorPos = null + + this.mentionDenotationChars = ['@'] + this.maxChars = 31 + this.offsetTop = 16 + this.offsetLeft = 0 + this.isolateCharacter = false + + this.quill = quill + + this.onMentionChange = options.onMentionChange + this.mentionContainer = options.mentionContainer + + options.onInit(this) + quill.on('text-change', this.onTextChange.bind(this)) + quill.on('selection-change', this.onSelectionChange.bind(this)) + } + + showMentionContainer() { + this.mentionContainer.style.visibility = 'hidden' + this.mentionContainer.style.display = '' + this.setMentionContainerPosition() + } + + hideMentionContainer() { + this.mentionContainer.style.display = 'none' + } + + insertMention(data: { id: string; displayName: string; userName: string }) { + if (!data || !this.cursorPos) { + return + } + + this.quill.deleteText( + this.mentionCharPos, + this.cursorPos - this.mentionCharPos, + 'user' + ) + this.quill.insertEmbed(this.mentionCharPos, 'mention', data, 'user') + this.quill.insertText(this.mentionCharPos + 1, ' ', 'user') + this.quill.setSelection(this.mentionCharPos + 2, 'user') + } + + hasValidChars(s: string) { + return REGEXP_DISPLAY_NAME.test(s) + } + + containerBottomIsNotVisible(topPos: number, containerPos: any) { + const mentionContainerBottom = + topPos + this.mentionContainer.offsetHeight + containerPos.top + return mentionContainerBottom > window.pageYOffset + window.innerHeight + } + + containerRightIsNotVisible(leftPos: number, containerPos: any) { + const rightPos = + leftPos + this.mentionContainer.offsetWidth + containerPos.left + const browserWidth = + window.pageXOffset + document.documentElement.clientWidth + return rightPos > browserWidth + } + + setMentionContainerPosition() { + // @ts-ignore + const containerPos = this.quill.container.getBoundingClientRect() + const mentionCharPos = this.quill.getBounds(this.mentionCharPos) + const containerHeight = this.mentionContainer.offsetHeight + + let topPos = this.offsetTop + let leftPos = this.offsetLeft + + /** + * handle horizontal positioning + */ + leftPos += mentionCharPos.left + if (this.containerRightIsNotVisible(leftPos, containerPos)) { + const containerWidth = this.mentionContainer.offsetWidth + this.offsetLeft + const quillWidth = containerPos.width + leftPos = quillWidth - containerWidth + } + + /** + * handle vertical positioning + */ + topPos += mentionCharPos.bottom + if (this.containerBottomIsNotVisible(topPos, containerPos)) { + let overMentionCharPos = this.offsetTop * -1 + overMentionCharPos += mentionCharPos.top + topPos = overMentionCharPos - containerHeight + } + + this.mentionContainer.style.top = `${topPos}px` + this.mentionContainer.style.left = `${leftPos}px` + this.mentionContainer.style.visibility = 'visible' + } + + handleChange() { + const range = this.quill.getSelection() + if (range == null) { + return + } + + this.cursorPos = range.index + const startPos = Math.max(0, this.cursorPos - this.maxChars) + const beforeCursorPos = this.quill.getText( + startPos, + this.cursorPos - startPos + ) + const mentionCharIndex = this.mentionDenotationChars.reduce((prev, cur) => { + const previousIndex = prev + const mentionIndex = beforeCursorPos.lastIndexOf(cur) + return mentionIndex > previousIndex ? mentionIndex : previousIndex + }, -1) + + if (mentionCharIndex <= -1) { + this.hideMentionContainer() + return + } + + if ( + this.isolateCharacter && + !( + mentionCharIndex === 0 || + !!beforeCursorPos[mentionCharIndex - 1].match(/\s/g) + ) + ) { + this.hideMentionContainer() + return + } + + const mentionCharPos = + this.cursorPos - (beforeCursorPos.length - mentionCharIndex) + const textAfter = beforeCursorPos.substring(mentionCharIndex + 1) + this.mentionCharPos = mentionCharPos + + if (!this.hasValidChars(textAfter)) { + this.hideMentionContainer() + return + } + + this.onMentionChange(textAfter) + this.showMentionContainer() + } + + onTextChange(delta: any, oldDelta: any, source: string) { + if (source === 'user') { + this.handleChange() + } + } + + onSelectionChange(range: any) { + if (range && range.length === 0) { + this.handleChange() + } else { + this.hideMentionContainer() + } + } +} + +Quill.register('modules/mention', Mention) + +export default Mention diff --git a/components/Editor/styles.css b/components/Editor/styles.css index 7c6eea3133..7dee1c5e82 100644 --- a/components/Editor/styles.css +++ b/components/Editor/styles.css @@ -5,3 +5,16 @@ min-height: 50vh; } } + +.mention-container { + position: absolute; + + width: 20rem; + visibility: hidden; + + color: var(--color-black); + border-radius: 2px; + background: var(--color-white); + box-shadow: var(--shadow-dark); + border: 1px solid var(--color-line-grey-light); +} diff --git a/components/Editor/utils/embedUrl.ts b/components/Editor/utils/embedUrl.ts new file mode 100644 index 0000000000..274fae4a16 --- /dev/null +++ b/components/Editor/utils/embedUrl.ts @@ -0,0 +1,36 @@ +export const code = (value: string) => { + if (!value) { + return '' + } + + if (value.match(/http(s)?:\/\/jsfiddle.net\//)) { + const path = new URL(value).pathname + return `https://jsfiddle.net${path}${ + path.endsWith('/') ? '' : '/' + }embedded/` + } + return '' +} + +export const video = (value: string) => { + if (!value) { + return '' + } + + let id: string | null + if (value.match('(http(s)?://)?(www.)?youtube|youtu.be')) { + id = value.match('embed') + ? value.split(/embed\//)[1].split('"')[0] + : value.split(/v\/|v=|youtu\.be\//)[1].split(/[?&]/)[0] + return 'https://www.youtube.com/embed/' + id + '?rel=0' + } else if (value.match(/vimeo.com\/(\d+)/)) { + const matches = value.match(/vimeo.com\/(\d+)/) + id = matches && matches[1] + return 'http://player.vimeo.com/video/' + id + } else if (value.match(/id_(.*)\.html/i)) { + const matches = value.match(/id_(.*)\.html/i) + id = matches && matches[1] + return 'http://player.youku.com/embed/' + id + } + return '' +} diff --git a/components/Editor/utils/lineBreakMatcher.ts b/components/Editor/utils/lineBreakMatcher.ts new file mode 100644 index 0000000000..a9ddcb2645 --- /dev/null +++ b/components/Editor/utils/lineBreakMatcher.ts @@ -0,0 +1,13 @@ +import { Quill } from 'react-quill' + +const Delta = Quill.import('delta') + +const lineBreakMatcher = (node: HTMLElement, delta: any) => { + return node.classList.contains('smart') + ? new Delta().insert({ + smartBreak: false + }) + : delta +} + +export default lineBreakMatcher diff --git a/components/Editor/utils/videoUrl.ts b/components/Editor/utils/videoUrl.ts deleted file mode 100644 index b29b263e75..0000000000 --- a/components/Editor/utils/videoUrl.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default (url: string) => { - let id: string | null - - if (url.match('(http(s)?://)?(www.)?youtube|youtu.be')) { - id = url.match('embed') - ? url.split(/embed\//)[1].split('"')[0] - : url.split(/v\/|v=|youtu\.be\//)[1].split(/[?&]/)[0] - return 'https://www.youtube.com/embed/' + id + '?rel=0' - } else if (url.match(/vimeo.com\/(\d+)/)) { - const matches = url.match(/vimeo.com\/(\d+)/) - id = matches && matches[1] - return 'http://player.vimeo.com/video/' + id - } else if (url.match(/id_(.*)\.html/i)) { - const matches = url.match(/id_(.*)\.html/i) - id = matches && matches[1] - - return 'http://player.youku.com/embed/' + id - } - - return false -} diff --git a/components/Form/CommentForm/index.tsx b/components/Form/CommentForm/index.tsx index fc79c45607..db56c91068 100644 --- a/components/Form/CommentForm/index.tsx +++ b/components/Form/CommentForm/index.tsx @@ -12,6 +12,7 @@ import { Translate } from '~/components/Language' import { Spinner } from '~/components/Spinner' import { ViewerContext } from '~/components/Viewer' +import { dom } from '~/common/utils' import ICON_POST from '~/static/icons/post.svg?sprite' import styles from './styles.css' @@ -85,14 +86,15 @@ const CommentForm = ({ const isValid = !!content const handleSubmit = (event: React.FormEvent) => { + const mentions = dom.getAttributes('data-id', content) const input = { id: commentId, comment: { content, replyTo: replyToId, articleId, - parentId - // mentions: + parentId, + mentions } } @@ -117,7 +119,16 @@ const CommentForm = ({ ) }) .catch((result: any) => { - // TODO: Handle error + window.dispatchEvent( + new CustomEvent('addToast', { + detail: { + color: 'red', + content: ( + + ) + } + }) + ) }) .finally(() => { setSubmitting(false) @@ -126,7 +137,10 @@ const CommentForm = ({ return (
- + setContent(value)} + />
{extraButton && extraButton} + +
+ + + +
+
+ © {year} Matters +
+
+ + + + ) +} + +export default Footer diff --git a/pages/Misc/About/Goal.tsx b/pages/Misc/About/Goal.tsx new file mode 100644 index 0000000000..6751d06782 --- /dev/null +++ b/pages/Misc/About/Goal.tsx @@ -0,0 +1,43 @@ +import { Translate } from '~/components/Language' + +import IMAGE_GOAL from '~/static/images/about-2.svg' + +import styles from './styles.css' + +const Goal = () => ( +
+
+
+ +
+ +
+

+ +

+

+ +

+

+ +

+

+ +

+
+
+ + +
+) + +export default Goal diff --git a/pages/Misc/About/Reports.tsx b/pages/Misc/About/Reports.tsx new file mode 100644 index 0000000000..19682ce360 --- /dev/null +++ b/pages/Misc/About/Reports.tsx @@ -0,0 +1,93 @@ +import { Translate } from '~/components/Language' + +import styles from './styles.css' + +const Reports = () => ( +
+
+
+

+ +

+
+
+ + + + +
+) + +export default Reports diff --git a/pages/Misc/About/Slogan.tsx b/pages/Misc/About/Slogan.tsx new file mode 100644 index 0000000000..9e6b5aa3c1 --- /dev/null +++ b/pages/Misc/About/Slogan.tsx @@ -0,0 +1,86 @@ +import { useContext } from 'react' +import { Waypoint } from 'react-waypoint' + +import { Button } from '~/components/Button' +import { HeaderContext } from '~/components/GlobalHeader/Context' +import { Translate } from '~/components/Language' + +import { PATHS } from '~/common/enums' +import IMAGE_SLOGAN_LG from '~/static/images/about-1-lg.svg' +import IMAGE_SLOGAN_MD from '~/static/images/about-1-md.svg' +import IMAGE_SLOGAN_SM from '~/static/images/about-1-sm.svg' +import IMAGE_SLOGAN_XL from '~/static/images/about-1-xl.svg' + +import styles from './styles.css' + +const Slogan = () => { + const { updateHeaderState } = useContext(HeaderContext) + + return ( +
+
+
+
+ { + updateHeaderState({ type: 'about', bgColor: 'transparent' }) + }} + onLeave={() => { + updateHeaderState({ type: 'about', bgColor: 'default' }) + }} + onPositionChange={({ currentPosition }) => { + if (currentPosition === 'above') { + updateHeaderState({ type: 'about', bgColor: 'default' }) + } + }} + /> + +

+ +
+ +

+ +
+ +
+
+
+
+ + + +
+ ) +} + +export default Slogan diff --git a/pages/Misc/About/index.tsx b/pages/Misc/About/index.tsx index 2bcdacee3f..8d837a64ad 100644 --- a/pages/Misc/About/index.tsx +++ b/pages/Misc/About/index.tsx @@ -1,40 +1,35 @@ -import { useContext } from 'react' +import { useContext, useEffect } from 'react' -import { Head, LanguageContext, PageHeader, Translate } from '~/components' +import { Head } from '~/components' +import { HeaderContext } from '~/components/GlobalHeader/Context' -import styles from '~/common/styles/utils/content.article.css' -import { translate } from '~/common/utils' - -import MiscTab from '../MiscTab' -import content from './content' +import Features from './Features' +import Footer from './Footer' +import Goal from './Goal' +import Reports from './Reports' +import Slogan from './Slogan' +import styles from './styles.css' export default () => { - const { lang } = useContext(LanguageContext) + const { updateHeaderState } = useContext(HeaderContext) + useEffect(() => { + updateHeaderState({ type: 'about', bgColor: 'transparent' }) + return () => updateHeaderState({ type: 'default' }) + }, []) return (
-
-
- -
-
- } - /> -
-
- -
+
+ + + + +
+
+ +
) } diff --git a/pages/Misc/About/styles.css b/pages/Misc/About/styles.css new file mode 100644 index 0000000000..0646c45398 --- /dev/null +++ b/pages/Misc/About/styles.css @@ -0,0 +1,289 @@ +h2 { + @mixin font-serif; + font-size: 1.7rem; + line-height: 1.4; + + @media (--sm-up) { + font-size: 2.5rem; + } +} + +.slogan { + @mixin flex-center-space-between; + align-items: flex-start; + + position: relative; + padding-top: 8.125rem; + margin-top: calc((var(--spacing-loose) + var(--global-header-height)) * -1); + margin-top: calc( + (var(--spacing-loose) + var(--global-header-height) + 1px) * -1 + ); + height: 100vh; + + background-color: var(--color-bg-green); + background-repeat: no-repeat; + background-position: right bottom; + background-size: contain; + text-align: center; + + @media (--sm-up) { + align-items: center; + padding-top: 0; + margin-top: calc( + (var(--spacing-x-loose) + var(--global-header-height)) * -1 + ); + margin-top: calc( + (var(--spacing-x-loose) + var(--global-header-height) + 1px) * -1 + ); + height: 50rem; + background-position: right center; + text-align: left; + } + + & img { + position: absolute; + top: 0; + right: 0; + height: 100%; + } + + & .buttons { + margin-top: var(--spacing-x-loose); + } +} + +.goal { + padding: 4rem 0; + + & img { + display: block; + margin: 0 auto; + width: 20rem; + height: 20rem; + } + + & h2 { + margin-top: var(--spacing-x-loose); + text-align: center; + } + + & p { + margin-top: var(--spacing-default); + } + + @media (--sm-up) { + padding: 8rem 0; + + & h2 { + margin-top: 0; + text-align: left; + } + } +} + +.features { + margin-top: 2.375rem; + background-color: var(--color-bg-yellow); + + & .title { + position: relative; + text-align: center; + + & h2 { + position: absolute; + top: 0; + left: 0; + width: 100%; + margin-top: -2.375rem; + } + } + + & .feature-section { + padding-top: 4rem; + + &:last-child { + padding-bottom: 4rem; + } + + & .intro { + text-align: center; + + & h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-light); + } + & p { + margin-top: var(--spacing-tight); + margin-bottom: var(--spacing-loose); + color: var(--color-matters-gold); + font-size: var(--font-size-sm); + } + } + } + + @media (--sm-up) { + margin-top: 3.5rem; + background: linear-gradient( + 90deg, + var(--color-bg-yellow) 50%, + transparent 50% + ) + no-repeat; + + & .title { + text-align: left; + + & h2 { + margin-top: -3.5rem; + } + } + + & .feature-section { + padding-top: 9rem; + + &:last-child { + padding-bottom: 9rem; + } + + & .intro { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + text-align: left; + + & p { + margin-bottom: 0; + } + } + } + } +} + +.reports { + margin-top: 5rem; + margin-bottom: 4rem; + + & h2 { + margin-bottom: var(--spacing-x-loose); + text-align: center; + } + + & .item { + margin-bottom: var(--spacing-tight); + + & a { + @mixin all-transition; + position: relative; + display: block; + padding: var(--spacing-tight); + background: var(--color-grey-lighter); + + &:hover, + &:focus { + background: var(--color-bg-green); + + & h3 { + color: var(--color-matters-green); + } + } + + & h3 { + @mixin font-serif; + margin-bottom: var(--spacing-x-tight); + font-size: var(--font-size-xl); + line-height: 1.3333333; + } + } + } + + @media (--sm-up) { + margin-top: 7.5rem; + margin-bottom: 4.5rem; + + & .item { + margin-bottom: 3rem; + + & a { + height: 17rem; + + & cite { + position: absolute; + bottom: var(--spacing-tight); + left: var(--spacing-tight); + right: var(--spacing-tight); + color: var(--color-grey-darker); + font-style: normal; + font-weight: var(--font-weight-medium); + } + } + } + } +} + +.footer { + padding: 4.5rem 0 2rem; + + background: var(--color-grey-lighter); + text-align: center; + + & .footer-section { + margin-bottom: 4rem; + } + + & h2 { + font-size: var(--font-size-lg); + margin-bottom: var(--spacing-default); + } + & p { + @mixin font-serif; + + & + p { + margin-top: var(--spacing-default); + } + } + & .socials { + & :global(> *) { + display: inline-block; + margin: 0 calc(var(--spacing-default) / 2); + } + } + + & form { + display: flex; + + & input { + display: inline-block; + padding: var(--spacing-x-tight); + margin-right: var(--spacing-tight); + height: 2.5rem; + + background: var(--color-white); + border-bottom: 1px solid var(--color-grey-light); + } + } + + & .copyright { + @mixin font-serif; + + font-size: var(--font-size-sm); + color: var(--color-grey-dark); + } + + @media (--sm-up) { + padding: 7.5rem 0; + text-align: left; + + & .socials { + & :global(> *) { + margin-left: 0; + margin-right: var(--spacing-default); + margin-bottom: var(--spacing-x-tight); + } + } + + & .copyright { + color: var(---grey-dark); + } + } +} diff --git a/postcss.config.js b/postcss.config.js index cbe191bb21..affa58764e 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -14,7 +14,8 @@ module.exports = { './common/styles/variables/colors.css', './common/styles/variables/sizing.css', './common/styles/variables/spacing.css', - './common/styles/variables/typography.css' + './common/styles/variables/typography.css', + './common/styles/variables/shadows.css' ] }), require('postcss-calc'), diff --git a/static/icons/footer-facebook.svg b/static/icons/footer-facebook.svg new file mode 100644 index 0000000000..98228366af --- /dev/null +++ b/static/icons/footer-facebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/footer-instagram.svg b/static/icons/footer-instagram.svg new file mode 100644 index 0000000000..51b94205e7 --- /dev/null +++ b/static/icons/footer-instagram.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/footer-medium.svg b/static/icons/footer-medium.svg new file mode 100644 index 0000000000..bcd94c1ae1 --- /dev/null +++ b/static/icons/footer-medium.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/footer-twitter.svg b/static/icons/footer-twitter.svg new file mode 100644 index 0000000000..6b13a96e68 --- /dev/null +++ b/static/icons/footer-twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/footer-wechat.svg b/static/icons/footer-wechat.svg new file mode 100644 index 0000000000..783b5716ce --- /dev/null +++ b/static/icons/footer-wechat.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/footer-weibo.svg b/static/icons/footer-weibo.svg new file mode 100644 index 0000000000..d221bffd3e --- /dev/null +++ b/static/icons/footer-weibo.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/images/about-1-lg.svg b/static/images/about-1-lg.svg new file mode 100644 index 0000000000..0023514503 --- /dev/null +++ b/static/images/about-1-lg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/about-1-md.svg b/static/images/about-1-md.svg new file mode 100644 index 0000000000..c4f95c3412 --- /dev/null +++ b/static/images/about-1-md.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/about-1-sm.svg b/static/images/about-1-sm.svg new file mode 100644 index 0000000000..4c64da45d4 --- /dev/null +++ b/static/images/about-1-sm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/about-1-xl.svg b/static/images/about-1-xl.svg new file mode 100644 index 0000000000..204a8d8dfc --- /dev/null +++ b/static/images/about-1-xl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/about-2.svg b/static/images/about-2.svg new file mode 100644 index 0000000000..c786cadb91 --- /dev/null +++ b/static/images/about-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/about-3-1.png b/static/images/about-3-1.png new file mode 100644 index 0000000000..c08ecb0478 Binary files /dev/null and b/static/images/about-3-1.png differ diff --git a/static/images/about-3-2.png b/static/images/about-3-2.png new file mode 100644 index 0000000000..ff7c36e8ee Binary files /dev/null and b/static/images/about-3-2.png differ diff --git a/static/images/about-3-3.png b/static/images/about-3-3.png new file mode 100644 index 0000000000..5ba3da899a Binary files /dev/null and b/static/images/about-3-3.png differ diff --git a/static/images/cover-fallback.jpg b/static/images/cover-fallback.jpg index d4cc3860be..6eea3f5d7d 100644 Binary files a/static/images/cover-fallback.jpg and b/static/images/cover-fallback.jpg differ diff --git a/static/images/download-android.svg b/static/images/download-android.svg index 29603ad843..b83bd656af 100644 --- a/static/images/download-android.svg +++ b/static/images/download-android.svg @@ -1,13 +1 @@ - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/download-ios.svg b/static/images/download-ios.svg index d293f74471..8dec89e6e7 100644 --- a/static/images/download-ios.svg +++ b/static/images/download-ios.svg @@ -1,23 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/illustration-avatar.svg b/static/images/illustration-avatar.svg index 624e6ed40e..250b422305 100644 --- a/static/images/illustration-avatar.svg +++ b/static/images/illustration-avatar.svg @@ -1,45 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/illustration-empty-comment.svg b/static/images/illustration-empty-comment.svg index 5df529de53..06552ce710 100644 --- a/static/images/illustration-empty-comment.svg +++ b/static/images/illustration-empty-comment.svg @@ -1,104 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/illustration-empty.svg b/static/images/illustration-empty.svg index 13d1cdfc36..59612f8398 100644 --- a/static/images/illustration-empty.svg +++ b/static/images/illustration-empty.svg @@ -1,32 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/intro.jpg b/static/images/intro.jpg index 8c78947243..4af35d744c 100644 Binary files a/static/images/intro.jpg and b/static/images/intro.jpg differ diff --git a/static/images/publish-1.svg b/static/images/publish-1.svg index 27fa638a1a..3b9fa2cf4f 100644 --- a/static/images/publish-1.svg +++ b/static/images/publish-1.svg @@ -1,62 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/publish-2.svg b/static/images/publish-2.svg index 5b79f8bd37..48b8efda4f 100644 --- a/static/images/publish-2.svg +++ b/static/images/publish-2.svg @@ -1,115 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/publish-3.svg b/static/images/publish-3.svg index e052a6e6eb..2436480852 100644 --- a/static/images/publish-3.svg +++ b/static/images/publish-3.svg @@ -1,67 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/images/publish-4.svg b/static/images/publish-4.svg index b448e23e30..21d7266244 100644 --- a/static/images/publish-4.svg +++ b/static/images/publish-4.svg @@ -1,63 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/static/opensearach.xml b/static/opensearach.xml new file mode 100644 index 0000000000..8142f418b5 --- /dev/null +++ b/static/opensearach.xml @@ -0,0 +1,14 @@ + + + UTF-8 + UTF-8 + Matters + Matters - 搜尋文章、標籤、作者 + + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAABAlBMVEX////GoUzEpEzFpUzFpUzGpEvCpE/EpEvFpE2qqlXDpUvFpUu/v0DFpk3DpkzEp0rFpkvGpUzFpUvEpUvGpE3Ipk7MmU3EpE3FpU3GqkfFpU3GpEvDo0zIpEnFpEvFpUzFpUzFpUzGpUzFpky/n0DGpk3Epk3EpU3Dp0rGpUzDqErEpEvFpE3FpUzFpUvFpkzGpUvEpUzGpErEpEzGqlXFpkzGpk3EpUvGpUvGpU3Epky/qlXEpk3//wDFpk3FpEvFpU3FpUvGpUzEp07GpkrFpEzGpUzGpUvEpUzFpEzFpUzGpk3Hp1DFpUzGp07FpUvFpE3Gpk22tknFpUrEpUzFpU28LPNQAAAAVnRSTlMAG3mRkIEqX5IDEXcEjE00XDafUlouCjhgEplwQByVr7yNk4kIKFaCN5cmmKOnsJpVoS2HCaugQX10rgyPAXuElmlvGkhlXo5sdn94IKgxWEZQBz6liPUxr/EAAADKSURBVBjTNc/JQoJQAEDRCzwEFUyQetgrKxxSoVHDrGywcs7M6v9/pYV2l2d3AdB0QwjDzLDNsjXIkjPzDgBuAdgpAp7vAKUAdvdkiAQvD+V9QB3I8NAGzAyBBFTl6PgkAnIm1RrUldU4bbbagIEAYpWcnV9EAhBbuNSvrlsb6HQhVjdxkkYCMGj04Lbvde7uqw+g6Tj+gN5j+vScvAzBfoW3dxiNJ9PZfEDBAhj2YZF+LOHT3cyt/K+1DLvfldL/rrNylz+/QRngD/N4FMUfQ5V3AAAAAElFTkSuQmCC + + + https://matters.news/search + + +