diff --git a/@types/project.d.ts b/@types/project.d.ts index e02f403..cd3ce70 100644 --- a/@types/project.d.ts +++ b/@types/project.d.ts @@ -10,7 +10,7 @@ declare module '*.woff2'; declare module '*.txt'; declare module 'cother' { - export type Pane = 'html' | 'css' | 'javascript' | 'output'; + export type Pane = 'html' | 'css' | 'javascript' | 'result'; export type ReduxState = { app: { diff --git a/package.json b/package.json index 7c18fcc..6308d5d 100755 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "next build && next export", "serve": "next start", "serve-static": "serve -c serve.json", - "deploy-firebase": "node run deploy" + "deploy-firebase": "node run deploy", + "lint": "tsc --noEmit && eslint --ext .ts --ext .tsx src/" }, "dependencies": { "@emotion/react": "^11.4.0", @@ -18,6 +19,8 @@ "@fortawesome/free-regular-svg-icons": "^5.15.3", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/react-fontawesome": "^0.1.14", + "@popperjs/core": "^2.9.2", + "copy-to-clipboard": "^3.3.1", "css-element-queries": "^1.2.3", "facepaint": "^1.2.1", "lodash": "^4.17.21", @@ -28,7 +31,9 @@ "react": "17.0.2", "react-dom": "17.0.2", "react-ga": "^3.3.0", + "react-popper": "^2.2.5", "react-redux": "^7.2.4", + "react-transition-group": "^4.4.2", "redux-thunk": "^2.3.0" }, "devDependencies": { @@ -36,6 +41,8 @@ "@types/lodash": "^4.14.170", "@types/qs": "^6.9.6", "@types/react": "17.0.6", + "@types/react-dom": "^17.0.6", + "@types/react-transition-group": "^4.4.1", "@typescript-eslint/eslint-plugin": "^4.24.0", "@typescript-eslint/parser": "^4.24.0", "eslint": "^7.27.0", diff --git a/src/components/base/popper-content.tsx b/src/components/base/popper-content.tsx new file mode 100644 index 0000000..4c66aba --- /dev/null +++ b/src/components/base/popper-content.tsx @@ -0,0 +1,193 @@ +/* eslint-disable no-console */ +// https://dev.to/tannerhallman/using-usepopper-to-create-a-practical-dropdown-5bf8 +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Placement } from '@popperjs/core'; +import { Popper as ReactPopper } from 'react-popper'; +import { CSSTransitionProps } from 'react-transition-group/CSSTransition'; +import { CSSTransition } from 'react-transition-group'; +import merge from 'lodash/merge'; +import { useTheme } from '@emotion/react'; +import { isBrowser, useDidMount, useDidUpdate, usePrevious } from 'src/utils'; + +export type PopperContentProps = React.HTMLAttributes & { + arrow?: boolean; + arrowClassName?: string; + className?: string; + fallbackPlacements?: Array; + inline?: boolean; + log?: boolean; + open?: boolean; + placement?: Placement; + target: HTMLElement | SVGElement; + transition?: CSSTransitionProps; +}; + +type State = { + open: boolean; +}; + +const PopperContent = React.forwardRef((props, ref) => { + const { + arrow = true, + arrowClassName, + children, + className, + fallbackPlacements = [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + ], + inline = false, + log = false, + open = false, + placement = 'auto', + target, + transition: _transition, + ...rest + } = props; + const [state, setState] = React.useState({ open: false }); + const theme = useTheme(); + const prevProps = usePrevious(props); + const elementRef = React.useRef(null); + const transitionDefault: CSSTransitionProps = { + appear: true, + enter: true, + exit: true, + timeout: theme.animation.timing.normal, + classNames: { + appear: 'appear', + appearActive: 'appear-active', + appearDone: 'appear-done', + enter: 'enter', + enterActive: 'enter-active', + enterDone: 'enter-done', + exit: 'exit', + exitActive: 'exit-active', + exitDone: 'exit-done', + }, + }; + const transition = merge(transitionDefault, _transition); + + useDidMount(() => { + if (open) { + setState({ open: true }); + } + const element = elementRef.current; + if (element && element.childNodes && element.childNodes[0]) { + const childNodes = element.childNodes[0] as HTMLElement | null; + if (childNodes && 'focus' in childNodes) { + childNodes.focus(); + } + } + }); + + useDidUpdate(() => { + if (prevProps && !prevProps.open && open) { + setState({ open: true }); + } + }, [open]); + + const handleTransitionExited = (node: HTMLElement) => { + setState({ open: false }); + transition.onExited && transition.onExited(node); + }; + + const renderChildren = () => { + return ( + + + {({ ref: popperRef, style, placement: _placement, arrowProps }) => { + return ( +
+ {children} + {arrow && ( +
+ )} +
+ ); + }} + + + ); + }; + + if (isBrowser && state.open) { + if (inline) { + return renderChildren(); + } else { + return ReactDOM.createPortal(
{renderChildren()}
, document.body); + } + } + + return null; +}); + +export default PopperContent; diff --git a/src/components/base/tooltip/index.tsx b/src/components/base/tooltip/index.tsx new file mode 100644 index 0000000..9b86553 --- /dev/null +++ b/src/components/base/tooltip/index.tsx @@ -0,0 +1,274 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import React from 'react'; +import { jsx, ThemeContext, SerializedStyles } from '@emotion/react'; +import isBoolean from 'lodash/isBoolean'; +import omit from 'lodash/omit'; +import { ThemeLib } from 'src/styles/theme'; +import PopperContent, { PopperContentProps } from '../popper-content'; +import { getTarget, TargetPropType } from 'src/utils'; +import createStyles from './styles'; + +type TooltipProps = Pick & { + arrowClassName?: string; + children: React.ReactNode; + elevation: 'none' | keyof ThemeLib['elevation']; + styles?: { + base?: SerializedStyles; + inner?: SerializedStyles; + }; + target: TargetPropType; + /** @default "hover" */ + trigger: 'click' | 'hover' | 'focus' | 'manual'; +}; + +type State = { + open: boolean; +}; + +class Tooltip extends React.Component { + static defaultProps: Pick = { + arrowClassName: 'tooltip-arrow', + elevation: 'raised', + trigger: 'hover', + }; + + static contextType = ThemeContext; + context!: ThemeLib; + + delay: number; + mounted: boolean; + initialOpen?: boolean; + showTimeout?: NodeJS.Timeout; + hideTimeout?: NodeJS.Timeout; + popperContentRef: React.RefObject; + targetElement?: HTMLElement | SVGElement; + + constructor(props: TooltipProps) { + super(props); + + this.delay = 0; + this.mounted = false; + this.initialOpen = props.open; + this.popperContentRef = React.createRef(); + this.state = { + open: props.trigger === 'manual' && isBoolean(props.open) ? props.open : false, + }; + } + + componentDidMount() { + this.mounted = true; + + const target = getTarget(this.props.target); + if (target) { + this.targetElement = target; + this.addTargetEvents(); + } + + // listen to custom event triggered outside tooltip component + } + + componentDidUpdate(prevProps: TooltipProps) { + // props.trigger === 'manual' + if ( + this.props.trigger === 'manual' && + isBoolean(this.props.open) && + prevProps.open !== this.props.open + ) { + this.setState({ + open: this.props.open, + }); + } + + // If props.trigger change + if (prevProps.trigger !== this.props.trigger) { + this.removeTargetEvents(); + this.addTargetEvents(); + + if (this.props.trigger === 'manual') { + this.setState({ + open: isBoolean(this.props.open) ? this.props.open : this.state.open, + }); + } else if (this.props.trigger === 'focus') { + if ( + this.targetElement && + this.targetElement === document.activeElement && + !this.state.open + ) { + this.setState({ + open: true, + }); + } else { + if (this.state.open) { + this.setState({ + open: false, + }); + } + } + } else { + if (this.state.open) { + this.setState({ + open: false, + }); + } + } + } + + // If placement change + // Fix Popper wrong position + if (prevProps.placement !== this.props.placement && this.state.open) { + this.setState( + { + open: false, + }, + () => { + window.requestAnimationFrame(() => { + this.setState({ + open: true, + }); + }); + }, + ); + } + } + + componentWillUnmount() { + this.removeTargetEvents(); + this.mounted = false; + } + + handleDocumentClick = (event: MouseEvent | TouchEvent) => { + const { trigger } = this.props; + const target = event.target as HTMLElement | null; + const { current: popperContent } = this.popperContentRef; + + if (this.targetElement) { + if (this.targetElement.contains(target)) { + if (trigger === 'click') { + this.toggle(); + } + } else { + if ( + (trigger === 'click' && popperContent && !popperContent.contains(target)) || + trigger === 'hover' + ) { + this.hide(); + } + } + } + }; + + addTargetEvents = () => { + if (!this.targetElement) return; + + if (this.props.trigger === 'hover') { + this.targetElement.addEventListener('mouseover', this.show, true); + this.targetElement.addEventListener('mouseout', this.hide, true); + } else if (this.props.trigger === 'focus') { + this.targetElement.addEventListener('focus', this.show, true); + this.targetElement.addEventListener('blur', this.hide, true); + } + + ['click', 'touchstart'].forEach(e => + document.addEventListener(e as 'click' | 'touchstart', this.handleDocumentClick, true), + ); + }; + + removeTargetEvents = () => { + ['mouseover', 'focus'].forEach( + e => this.targetElement && this.targetElement.removeEventListener(e, this.show, true), + ); + ['mouseout', 'blur'].forEach( + e => this.targetElement && this.targetElement.removeEventListener(e, this.hide, true), + ); + ['click', 'touchstart'].forEach(e => + document.removeEventListener(e as 'click' | 'touchstart', this.handleDocumentClick, true), + ); + }; + + show = () => { + this.hideTimeout && clearTimeout(this.hideTimeout); + this.showTimeout = setTimeout(this.onShow, this.delay); + }; + + onShow = () => { + this.showTimeout && clearTimeout(this.showTimeout); + this.setState({ + open: true, + }); + }; + + hide = () => { + this.showTimeout && clearTimeout(this.showTimeout); + this.hideTimeout = setTimeout(this.onHide, this.delay); + }; + + onHide = () => { + this.hideTimeout && clearTimeout(this.hideTimeout); + // Prevent warning: Can't perform a React state update on an unmounted component. + if (this.mounted) { + this.setState({ + open: false, + }); + } + }; + + toggle = () => { + this.setState(prevState => ({ + open: !prevState.open, + })); + }; + + handleMouseOverContent = () => { + // Prevent hide when user hover on tooltip, only when this.props.trigger === 'hover' + if (this.props.trigger !== 'hover') return; + this.hideTimeout && clearTimeout(this.hideTimeout); + }; + + handleMouseLeaveContent = () => { + if (this.props.trigger !== 'hover') return; + this.showTimeout && clearTimeout(this.showTimeout); + this.hideTimeout = setTimeout(this.hide, this.delay); + }; + + render() { + const omittedProps: Array> = [ + 'open', + 'target', + 'trigger', + ]; + const { + arrowClassName, + children, + elevation, + styles: _styles, + ...rest + } = omit(this.props, omittedProps); + const nativeStyles = createStyles(this.context, elevation); + const styles = { + base: [nativeStyles.base, _styles && 'base' in _styles && _styles.base], + inner: [nativeStyles.inner, _styles && 'inner' in _styles && _styles.inner], + }; + + if (this.targetElement) { + return ( + +
{children}
+
+ ); + } + + return null; + } +} + +export default Tooltip; diff --git a/src/components/base/tooltip/styles.ts b/src/components/base/tooltip/styles.ts new file mode 100644 index 0000000..c729b7e --- /dev/null +++ b/src/components/base/tooltip/styles.ts @@ -0,0 +1,217 @@ +import { css } from '@emotion/react'; +import { ThemeLib } from 'src/styles/theme'; +import { elevationStyle } from 'src/styles/mixins'; + +const createStyles = (t: ThemeLib, elevation: 'none' | keyof ThemeLib['elevation']) => { + const arrowSize = 5; + const additionalPadding = arrowSize * 2; // additionalPadding must be greater than `arrowSize` + const { + animation: { easing, timing }, + } = t; + + const animationConfig = css` + transition-duration: ${timing.fast}ms; + transition-property: transform, opacity; + transition-timing-function: ${easing.fast}; + `; + + const translate = (placement: 'top' | 'right' | 'bottom' | 'left') => { + let pixel: number; + let move: string; + let initial: string; + + switch (placement) { + case 'top': + case 'left': + pixel = -t.spacing.xs; + break; + default: + pixel = t.spacing.xs; + } + + switch (placement) { + case 'top': + case 'bottom': + initial = `translateY(${pixel}px)`; + move = `translateY(0)`; + break; + default: + initial = `translateX(${pixel}px)`; + move = `translateX(0)`; + } + + return css` + &[data-placement^='${placement}'] { + &.appear, + &.enter { + transform: ${initial}; + } + &.appear-active, + &.enter-active { + transform: ${move}; + } + &.appear-done, + &.enter-done { + transform: ${move}; + } + } + `; + }; + + return { + base: css` + font-size: ${t.typography.size.small}px; + max-width: 260px; + z-index: ${t.zIndex.tooltip}; + + /* ---------------------------------------- */ + /* Animation */ + /* ---------------------------------------- */ + + &.appear, + &.enter { + opacity: 0.01; + } + &.appear-active, + &.enter-active { + opacity: 1; + ${animationConfig}; + } + &.appear-done, + &.enter-done { + opacity: 1; + } + + &.exit { + opacity: 1; + } + &.exit-active { + opacity: 0.01; + ${animationConfig}; + } + &.exit-done { + opacity: 0.01; + } + + ${translate('top')}; + ${translate('right')}; + ${translate('bottom')}; + ${translate('left')}; + + &[data-placement^='top'] { + padding-bottom: ${additionalPadding}px; + } + &[data-placement^='right'] { + padding-left: ${additionalPadding}px; + } + &[data-placement^='bottom'] { + padding-top: ${additionalPadding}px; + } + &[data-placement^='left'] { + padding-right: ${additionalPadding}px; + } + + /* ---------------------------------------- */ + /* Arrow */ + /* ---------------------------------------- */ + .tooltip-arrow { + width: 0; + height: 0; + + &::after { + position: absolute; + display: block; + content: ''; + border-color: transparent; + border-style: solid; + } + } + + &[data-placement^='top'] { + .tooltip-arrow { + bottom: 0; + + &::after { + bottom: ${additionalPadding - arrowSize}px; + left: -${arrowSize}px; + border-width: ${arrowSize}px ${arrowSize}px 0 ${arrowSize}px; + border-top-color: ${t.color.lightPrimary}; + } + } + } + + &[data-placement^='right'] { + .tooltip-arrow { + left: 0; + &::after { + left: ${additionalPadding - arrowSize}px; + top: -${arrowSize}px; + border-width: ${arrowSize}px ${arrowSize}px ${arrowSize}px 0; + border-right-color: ${t.color.lightPrimary}; + } + } + } + + &[data-placement^='bottom'] { + .tooltip-arrow { + top: 0; + &::after { + top: ${additionalPadding - arrowSize}px; + left: -${arrowSize}px; + border-width: 0 ${arrowSize}px ${arrowSize}px ${arrowSize}px; + border-bottom-color: ${t.color.lightPrimary}; + } + } + } + + &[data-placement^='left'] { + .tooltip-arrow { + right: 0; + &::after { + right: ${additionalPadding - arrowSize}px; + top: -${arrowSize}px; + border-width: ${arrowSize}px 0 ${arrowSize}px ${arrowSize}px; + border-left-color: ${t.color.lightPrimary}; + } + } + } + + &[data-placement='top-start'], + &[data-placement='bottom-start'] { + .tooltip-arrow[data-edge]::after { + left: -${arrowSize * 4}px; + } + } + + &[data-placement='top-end'], + &[data-placement='bottom-end'] { + .tooltip-arrow[data-edge]::after { + left: ${arrowSize * 2}px; + } + } + + &[data-placement='right-start'], + &[data-placement='left-start'] { + .tooltip-arrow[data-edge]::after { + top: -${arrowSize * 3}px; + } + } + + &[data-placement='right-end'], + &[data-placement='left-end'] { + .tooltip-arrow[data-edge]::after { + top: ${arrowSize}px; + } + } + `, + inner: css` + padding: ${t.spacing.xxs}px ${t.spacing.xs}px; + border-radius: ${t.border.radius.default}px; + background-color: ${t.color.lightPrimary}; + color: ${t.color.darkPrimary}; + ${elevationStyle(t, elevation)}; + `, + }; +}; + +export default createStyles; diff --git a/src/components/contextual/header/index.tsx b/src/components/contextual/header/index.tsx index e1f43fc..3e71813 100644 --- a/src/components/contextual/header/index.tsx +++ b/src/components/contextual/header/index.tsx @@ -3,12 +3,14 @@ import React, { Fragment } from 'react'; import { jsx, useTheme } from '@emotion/react'; import { useRouter } from 'next/router'; +import copy from 'copy-to-clipboard'; import { PROJECT_NAME, GITHUB_BTN_URL } from 'src/contants'; import Button from 'src/components/base/button'; import ButtonGroup from 'src/components/base/button-group'; import Icon from 'src/components/base/icon'; import Spinner from 'src/components/base/spinner'; -import { keys } from 'src/utils'; +import Tooltip from 'src/components/base/tooltip'; +import { keys, isBrowser } from 'src/utils'; import createStyles from './styles'; import logo from 'src/img/logo.svg'; @@ -38,7 +40,42 @@ const Header: React.VFC = props => { html: 'HTML', css: 'CSS', javascript: 'JS', - output: 'Output', + result: 'Result', + }; + const tooltipRef = React.useRef(null); + let currentUrl = ''; + if (isBrowser) { + currentUrl = window.location.href; + } + + React.useEffect(() => { + // detect click on Result iframe, then close Tooltip + const resultIframe = document.getElementById('result'); + if (resultIframe && resultIframe instanceof HTMLIFrameElement) { + resultIframe.contentDocument && + resultIframe.contentDocument.body.addEventListener( + 'mouseup', + handleResultIframeClick, + true, + ); + } + + return () => { + if (resultIframe && resultIframe instanceof HTMLIFrameElement) { + resultIframe.contentDocument && + resultIframe.contentDocument.body.removeEventListener( + 'mouseup', + handleResultIframeClick, + true, + ); + } + }; + }, []); + + const handleResultIframeClick = () => { + if (tooltipRef.current) { + tooltipRef.current.hide(); + } }; const handleBrandClick = (e: React.MouseEvent) => { @@ -62,6 +99,14 @@ const Header: React.VFC = props => { } }; + const handleCopyLink = (e: React.MouseEvent) => { + e.preventDefault(); + copy(currentUrl); + if (tooltipRef.current) { + tooltipRef.current.hide(); + } + }; + return (
@@ -89,6 +134,25 @@ const Header: React.VFC = props => { ) : ( +
+ Share + +
+ +
+
{currentUrl}
+ + Copy link + +
+
+
{totalUser} diff --git a/src/components/contextual/header/styles.tsx b/src/components/contextual/header/styles.tsx index d97e2cc..1101047 100644 --- a/src/components/contextual/header/styles.tsx +++ b/src/components/contextual/header/styles.tsx @@ -4,12 +4,14 @@ import { ThemeLib } from 'src/styles/theme'; const createStyles = (t: ThemeLib) => { return { wrapper: css` + position: relative; display: flex; flex-direction: row; align-items: center; width: 100%; height: 46px; padding: 0 ${t.spacing.m}px; + z-index: ${t.zIndex.header}; `, col1: css` display: flex; @@ -48,14 +50,54 @@ const createStyles = (t: ThemeLib) => { margin-right: ${t.spacing.m / 2}px; } `, + share: css` + display: flex; + align-items: center; + font-size: ${t.typography.size.small}px; + line-height: ${t.typography.size.small}px; + padding: 0 ${t.spacing.xs}px; + cursor: pointer; + + svg { + font-size: ${t.typography.size.medium}px; + margin-left: ${t.spacing.s / 2}px; + } + `, + tooltip: css` + max-width: unset; + `, + tooltipInner: css` + padding: ${t.spacing.xs}px ${t.spacing.m}px; + `, + tooltipContent: css` + display: flex; + flex-direction: row; + `, + tooltipUrl: css` + max-width: 240px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: ${t.spacing.xs}px; + `, + tooltipBtn: css` + font-weight: ${t.typography.weight.medium}; + transition: color ${t.animation.timing.fast}ms ${t.animation.easing.fast}; + + &:hover { + text-decoration: none; + } + `, userListIndicator: css` - display: inline-flex; + display: flex; align-items: center; font-size: ${t.typography.size.small}px; - margin-right: ${t.spacing.s}px; + line-height: ${t.typography.size.small}px; + padding: 0 ${t.spacing.xs}px; + margin-right: ${t.spacing.xs}px; svg { - font-size: 17px; + font-size: ${t.typography.size.medium}px; margin-left: ${t.spacing.s / 2}px; } `, diff --git a/src/pages/code-editor.tsx b/src/pages/code-editor.tsx index 62572d8..d540243 100644 --- a/src/pages/code-editor.tsx +++ b/src/pages/code-editor.tsx @@ -37,12 +37,13 @@ type CodeEditorProps = ReturnType & { theme: ThemeLib; }; -type PaneEditor = Exclude; +type PaneEditor = Exclude; type State = { err: boolean; editor: { [key in PaneEditor]: { + label: string; ready: boolean; content: string; }; @@ -130,14 +131,17 @@ class CodeEditor extends React.Component { currentUser: {}, editor: { html: { + label: 'HTML', ready: false, content: '', }, css: { + label: 'CSS', ready: false, content: '', }, javascript: { + label: 'JS', ready: false, content: '', }, @@ -150,7 +154,7 @@ class CodeEditor extends React.Component { } componentDidMount() { - const id = this.getQueryStringValue('id'); + const id = this.getQueryStringValue('session'); if (isNumeric(id) && id.length === 13) { this.dbPrefix = `documents-no-owner/${id}`; this.userId = Math.floor(Math.random() * 9999999999).toString(); @@ -355,7 +359,7 @@ class CodeEditor extends React.Component { } }); const lastActivePane = visiblePane.slice(-1)[0]; - const showSplitter = !(lastActivePane === pane && !this.props.activePane.includes('output')); + const showSplitter = !(lastActivePane === pane && !this.props.activePane.includes('result')); return ( @@ -365,6 +369,7 @@ class CodeEditor extends React.Component { }} style={style} > +
{this.state.editor[pane].label}
(ace.ref = c)} css={this.styles.editorContainer} /> {showSplitter && ( @@ -402,7 +407,7 @@ class CodeEditor extends React.Component { {keys(editor).map(pane => this.renderEditor(pane))} {/* Output */} - + {/* Use mask to prevent Splitter drag error */} {this.state.showIframeMask &&
}