Skip to content

Instantly share code, notes, and snippets.

@kmelve
Last active November 25, 2020 13:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kmelve/fa487c0af9fafafb8dc67fe49a26d27c to your computer and use it in GitHub Desktop.
Save kmelve/fa487c0af9fafafb8dc67fe49a26d27c to your computer and use it in GitHub Desktop.
Handle pasting of Github flavored markdown for the Portable Text array.
/**
Remember to install dependencies:
yarn add unified remark-parse remark-gfm remark-rehype rehype-stringify
*/
import unified from 'unified'
import parse from 'remark-parse'
import gfm from 'remark-gfm'
import remark2rehype from 'remark-rehype'
import stringify from 'rehype-stringify'
import blockTools from '@sanity/block-tools'
const uuid = () =>
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
export async function handlePaste (input) {
const { event, type, path } = input
const text = event.clipboardData.getData('text/plain')
// const json = event.clipboardData.getData('application/json')
if (text) {
const {contents} = await convertMDtoPortableText(text)
const patch = await convertHTMLtoPortableText(contents, type)
return patch
}
// return undefined to let the defaults do the work
return undefined
}
async function convertMDtoPortableText (markdownContent) {
const PT = await unified()
.use(parse)
.use(gfm)
.use(remark2rehype)
.use(stringify)
.process(markdownContent)
return PT
}
function convertHTMLtoPortableText (html, type, path) {
const hasCodeType = type.of.map(({ name }) => name).includes('code')
if (!hasCodeType) {
console.log(
'Run `sanity install @sanity/code-input, and add `type: "code"` to your schema.'
)
return
}
if (html && hasCodeType) {
const blocks = blockTools.htmlToBlocks(html, type, {
rules: [
{
deserialize(el, next, block) {
if (!el) {
return undefined
}
if (el.tagName.toLowerCase() !== 'table') {
return undefined
}
const table = el
const rows = table.children[1]
return block({
_type: 'table',
_key: uuid(),
rows: Array.from(rows.children).map((row) => {
return {
_type: 'row',
_key: uuid(),
cells: Array.from(row.children).map((cell) => ({
_type: 'cell',
_key: uuid(),
value: [
{
_type: 'block',
_key: uuid(),
markDefs: [],
children: [
{
_type: 'span',
_key: uuid(),
text: cell.textContent,
},
],
},
],
})),
}
}),
})
},
},
{
deserialize(el, next, block) {
/**
* `el` and `next` is DOM Elements
* learn all about them:
* https://developer.mozilla.org/en-US/docs/Web/API/Element
**/
if (
!el ||
!el.children ||
(el.tagName && el.tagName.toLowerCase() !== 'pre')
) {
return undefined
}
const code = el.children[0]
const childNodes =
code && code.tagName.toLowerCase() === 'code'
? code.childNodes
: el.childNodes
let text = ''
childNodes.forEach((node) => {
text += node.textContent
})
/**
* Return this as an own block (via block helper function),
* instead of appending it to a default block's children
*/
return block({
_type: 'code',
code: text,
})
},
},
],
})
// return an insert patch
return { insert: blocks, path }
}
}
import React, { useState, forwardRef, Fragment } from 'react'
import { BlockEditor } from 'part:@sanity/form-builder'
import Switch from 'part:@sanity/components/toggles/switch'
import css from './PTeditor.module.css'
import { handlePaste } from './handlePaste'
function CustomEditor(props, ref){
const [customPaste, setCustomPaste] = useState(false)
function handleCustomPaste () {
setCustomPaste({ customPaste: !customPaste })
}
const { value = [] } = props
const wordsPerMinute = 200
const plainText = blocksToText(value)
const wordTokens = plainText.split(/\w+/g).filter(Boolean)
const characterCount = plainText.length
const wordCount = wordTokens.length
const readingTime = Math.ceil(wordCount / wordsPerMinute)
return (
<div>
<BlockEditor
ref={ref}
onPaste={customPaste ? handlePaste : undefined}
{...props}
/>
<div className={css.infoBar}>
<div>
<div className={css.pill}>🔠  {characterCount}</div>
<div className={css.pill}>🚾  {wordCount}</div>
<div className={css.pill}>⏱  {readingTime} min </div>
</div>
<div className={css.pill}>
<Switch
label={`Markdown paste (${customPaste ? 'on' : 'off'})`}
onChange={handleCustomPaste}
checked={customPaste}
/>
</div>
</div>
</div>
)
}
const defaults = { nonTextBehavior: 'remove' }
function blocksToText(blocks, opts = {}) {
const options = Object.assign({}, defaults, opts)
return blocks
.map(block => {
if (block._type !== 'block' || !block.children) {
return options.nonTextBehavior === 'remove'
? ''
: `[${block._type} block]`
}
return block.children.map(child => child.text).join('')
})
.join('\n\n')
}
export default forwardRef(CustomEditor)
.infoBar {
padding: 0.5em 0;
display: flex;
@nest &>div+div {
padding-left: 0.5rem;
}
}
.pill {
display: inline-block;
}
.pill+.pill {
margin-left: 0.5em;
}
import React from 'react'
import PTEditor from './PTEditor'
export default {
title: 'Rich Text',
name: 'richText',
inputComponent: PTEditor,
type: 'array',
of: [
{
title: 'Block',
type: 'block',
},
{
title: 'Code Block',
type: 'code'
},
{
name: 'table',
type: 'object',
fields: [
{
name: 'rows',
type: 'array',
of: [
{
type: 'object',
name: 'row',
fields: [
{
name: 'cells',
type: 'array',
of: [
{
name: 'cell',
type: 'object',
fields: [
{
name: 'value',
type: 'blockContent',
}
]
}
]
}
]
}
]
}
]
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment