support ruby and better raw html display (#849)

* support ruby and better raw html display

* finish ruby render

* add kbd style

* if content is empty string, do not hide tag

* update changelog

* add auto complement to inline html

* opti slit words

* opti tool bar style

* support open inline a tag if it is a external link

* fix: auto complete

* add attribute white list

* add comment

* delete some commented codes
This commit is contained in:
Ran Luo 2019-04-04 02:31:34 +08:00 committed by Felix Häusler
parent f226f87dcb
commit 5d748eb196
18 changed files with 224 additions and 120 deletions

21
.github/CHANGELOG.md vendored
View File

@ -6,6 +6,25 @@ This update **fixes a XSS security vulnerability** when exporting a document.
- Minimum supported macOS version is 10.10 (Yosemite)
- Remove `lightColor` and `darkColor` in user preference (color change in view menu does not work any, and will remove when add custom theme.)
- We recommand user not use block element in paragraph, please use block element in html block.
*Not Recommand*
```md
foo<section>bar</section>zar
```
*Recommand*
```md
<div>
foo
<section>
bar
</section>
zar
</div>
```
**:cactus:Feature**
@ -21,6 +40,7 @@ This update **fixes a XSS security vulnerability** when exporting a document.
- Support maxOS `dark mode`, when you change `mode dark or light` in system, Mark Text will change its theme.
- Add new themes: Ulysses Light, Graphite Light, Material Dark and One Dark.
- Watch file changed in tabs and show a notice(autoSave is `false`) or update the file(autoSave is `true`)
- Support input inline Ruby charactors as raw html (#257)
**:butterfly:Optimization**
@ -38,6 +58,7 @@ This update **fixes a XSS security vulnerability** when exporting a document.
- Make table of contents in sidebar collapsible (#404)
- Hide titlebar control buttons in custom titlebar style
- Corrected hamburger menu offset
- Optimization of inline html displa, now you can nest other inline syntax in inline html(#849)
**:beetle:Bug fix**

View File

@ -117,6 +117,10 @@ span.ag-html-tag {
font-family: monospace;
}
span.ag-ruby {
position: relative;
vertical-align: bottom;
}
span.ag-math {
position: relative;
color: var(--editorColor);
@ -125,7 +129,8 @@ span.ag-math {
vertical-align: bottom;
}
.ag-math > .ag-math-render {
.ag-math > .ag-math-render,
.ag-ruby > .ag-ruby-render {
display: inline-block;
padding: .5rem;
border-radius: 4px;
@ -135,6 +140,12 @@ span.ag-math {
z-index: 1;
}
.ag-ruby > .ag-ruby-render {
padding-bottom: 0;
left: 50%;
transform: translateX(-50%);
}
div.ag-empty {
text-align: center;
color: var(--editorColor50);
@ -163,18 +174,19 @@ span.ag-math > .ag-math-render.ag-math-error {
white-space: nowrap;
}
.ag-hide.ag-ruby,
.ag-hide.ag-math {
width: auto;
height: auto;
}
.ag-hide.ag-ruby > .ag-ruby-text,
.ag-hide.ag-math > .ag-math-text {
display: inline-block;
width: 0;
height: 0;
overflow: hidden;
}
.ag-hide.ag-ruby > .ag-ruby-render,
.ag-hide.ag-math > .ag-math-render {
padding: 0;
top: 0;
@ -184,6 +196,7 @@ span.ag-math > .ag-math-render.ag-math-error {
background: transparent;
}
.ag-gray.ag-ruby > .ag-ruby-render::before
.ag-gray.ag-math > .ag-math-render::before {
border-width: 5px;
border-style: solid;
@ -195,6 +208,7 @@ span.ag-math > .ag-math-render.ag-math-error {
content: "";
}
.ag-hide.ag-ruby > .ag-ruby-render::before
.ag-hide.ag-math > .ag-math-render::before {
content: none;
}
@ -209,7 +223,7 @@ figure {
width: 100%;
user-select: none;
position: absolute;
top: -15px;
top: -20px;
left: 0;
display: none;
}
@ -720,3 +734,7 @@ span.ag-reference-link {
.ag-meta-or-ctrl a.ag-inline-rule {
cursor: pointer !important;
}
.ag-ruby-render {
user-select: none;
}

View File

@ -109,6 +109,9 @@ export const CLASS_OR_ID = genUpper2LowerKeyHash([
'AG_MATH',
'AG_MATH_TEXT',
'AG_MATH_RENDER',
'AG_RUBY',
'AG_RUBY_TEXT',
'AG_RUBY_RENDER',
'AG_MATH_ERROR',
'AG_EMPTY',
'AG_MATH_MARKER',
@ -238,3 +241,10 @@ export const isInElectron = window && window.process && window.process.type ===
export const isOsx = window && window.navigator && /Mac/.test(window.navigator.platform)
// http[s] (domain or IPv4 or localhost or IPv6) [port] /not-white-space
export const URL_REG = /^http(s)?:\/\/([a-z0-9\-._~]+\.[a-z]{2,}|[0-9.]+|localhost|\[[a-f0-9.:]+\])(:[0-9]{1,5})?\/[\S]+/i
// selected from https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
export const WHITELIST_ATTRIBUTES = [
'align', 'alt', 'checked', 'class', 'color', 'dir', 'disabled', 'for', 'height', 'hidden',
'href', 'id', 'lang', 'lazyload', 'rel', 'spellcheck', 'src', 'srcset', 'start', 'style',
'target', 'title', 'type', 'value', 'width'
]

View File

@ -26,6 +26,7 @@ const copyCutCtrl = ContentState => {
const removedElements = wrapper.querySelectorAll(
`.${CLASS_OR_ID['AG_TOOL_BAR']},
.${CLASS_OR_ID['AG_MATH_RENDER']},
.${CLASS_OR_ID['AG_RUBY_RENDER']},
.${CLASS_OR_ID['AG_HTML_PREVIEW']},
.${CLASS_OR_ID['AG_MATH_PREVIEW']},
.${CLASS_OR_ID['AG_COPY_REMOVE']},

View File

@ -50,7 +50,7 @@ const inputCtrl = ContentState => {
const key = start.key
const block = this.getBlock(key)
const paragraph = document.querySelector(`#${key}`)
let text = getTextContent(paragraph, [ CLASS_OR_ID['AG_MATH_RENDER'] ])
let text = getTextContent(paragraph, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ])
let needRender = false
let needRenderAll = false

View File

@ -198,8 +198,7 @@ const tabCtrl = ContentState => {
start.key === end.key &&
start.offset === end.offset &&
startBlock.type === 'span' &&
startBlock.functionType === 'codeLine' &&
startBlock.lang === 'markup'
(!startBlock.functionType || startBlock.functionType === 'codeLine' && startBlock.lang === 'markup')
) {
const { text } = startBlock
const lastWord = text.split(/\s+/).pop()

View File

@ -59,7 +59,9 @@ class ClickEvent {
// handler image and inline math preview click
const markedImageText = target.previousElementSibling
const mathRender = target.closest(`.${CLASS_OR_ID['AG_MATH_RENDER']}`)
const rubyRender = target.closest(`.${CLASS_OR_ID['AG_RUBY_RENDER']}`)
const mathText = mathRender && mathRender.previousElementSibling
const rubyText = rubyRender && rubyRender.previousElementSibling
if (markedImageText && markedImageText.classList.contains(CLASS_OR_ID['AG_IMAGE_MARKED_TEXT'])) {
eventCenter.dispatch('format-click', {
event,
@ -69,6 +71,8 @@ class ClickEvent {
selectionText(markedImageText)
} else if (mathText) {
selectionText(mathText)
} else if (rubyText) {
selectionText(rubyText)
}
// handler html preview click
const htmlPreview = target.closest(`.ag-function-html`)

View File

@ -1,9 +1,10 @@
import { beginRules, inlineRules } from './rules'
import { isLengthEven, union } from '../utils'
import { punctuation } from '../config'
import { punctuation, WHITELIST_ATTRIBUTES } from '../config'
const CAN_NEST_RULES = ['strong', 'em', 'link', 'del', 'image', 'a_link'] // image can not nest but it has children
// disallowed html tags in https://github.github.com/gfm/#raw-html
const disallowedHtmlTag = /(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)/i
const validateRules = Object.assign({}, inlineRules)
delete validateRules.em
delete validateRules.strong
@ -16,21 +17,22 @@ const validWidthAndHeight = value => {
return value >= 0 ? value : ''
}
const getSrcAlt = text => {
const SRC_REG = /src\s*=\s*("|')([^\1]+?)\1/
const ALT_REG = /alt\s*=\s*("|')([^\1]+?)\1/
const WIDTH_REG = /width\s*=\s*("|')([^\1]+?)\1/
const HEIGHT_REG = /height\s*=\s*("|')([^\1]+?)\1/
const srcMatch = SRC_REG.exec(text)
const src = srcMatch ? srcMatch[2] : ''
const altMatch = ALT_REG.exec(text)
const alt = altMatch ? altMatch[2] : ''
const widthMatch = WIDTH_REG.exec(text)
const width = widthMatch ? validWidthAndHeight(widthMatch[2]) : ''
const heightMatch = HEIGHT_REG.exec(text)
const height = heightMatch ? validWidthAndHeight(heightMatch[2]) : ''
const getAttributes = html => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const target = doc.querySelector('body').firstElementChild
if (!target) return null
const attrs = {}
for (const attr of target.getAttributeNames()) {
if (!WHITELIST_ATTRIBUTES.includes(attr)) continue
if (/width|height/.test(attr)) {
attrs[attr] = validWidthAndHeight(target.getAttribute(attr))
} else {
attrs[attr] = target.getAttribute(attr)
}
}
return { src, alt, width, height }
return attrs
}
const lowerPriority = (src, offset) => {
@ -364,57 +366,6 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top) => {
continue
}
// a_link `<a href="url">Anchor</a>`
const aLinkTo = inlineRules['a_link'].exec(src)
if (aLinkTo) {
pushPending()
tokens.push({
type: 'a_link',
raw: aLinkTo[0],
href: aLinkTo[3],
openTag: aLinkTo[1],
closeTag: aLinkTo[5],
anchor: aLinkTo[4],
parent: tokens,
range: {
start: pos,
end: pos + aLinkTo[0].length
},
children: tokenizerFac(aLinkTo[4], undefined, inlineRules, pos + aLinkTo[1].length, false)
})
src = src.substring(aLinkTo[0].length)
pos = pos + aLinkTo[0].length
continue
}
// html-image
const htmlImageTo = inlineRules['html_image'].exec(src)
if (htmlImageTo) {
const rawAttr = htmlImageTo[2]
const { src: imageSrc, alt, width, height } = getSrcAlt(rawAttr)
if (imageSrc) {
pushPending()
tokens.push({
type: 'html_image',
raw: htmlImageTo[0],
tag: htmlImageTo[1],
parent: tokens,
src: imageSrc,
width,
height,
alt,
range: {
start: pos,
end: pos + htmlImageTo[0].length
}
})
src = src.substring(htmlImageTo[0].length)
pos = pos + htmlImageTo[0].length
continue
}
}
// html escape
const htmlEscapeTo = inlineRules['html_escape'].exec(src)
if (htmlEscapeTo) {
@ -437,14 +388,43 @@ const tokenizerFac = (src, beginRules, inlineRules, pos = 0, top) => {
// html-tag
const htmlTo = inlineRules['html_tag'].exec(src)
if (htmlTo) {
let attrs
// handle comment
if (htmlTo && htmlTo[1] && !htmlTo[3]) {
const len = htmlTo[0].length
pushPending()
tokens.push({
type: 'html_tag',
raw: htmlTo[0],
tag: htmlTo[1],
tag: '<!---->',
openTag: htmlTo[1],
parent: tokens,
attrs: {},
range: {
start: pos,
end: pos + len
}
})
src = src.substring(len)
pos = pos + len
continue
}
if (htmlTo && !(disallowedHtmlTag.test(htmlTo[3])) && (attrs = getAttributes(htmlTo[0]))) {
const tag = htmlTo[3]
const html = htmlTo[0]
const len = htmlTo[0].length
pushPending()
tokens.push({
type: 'html_tag',
raw: html,
tag,
openTag: htmlTo[2],
closeTag: htmlTo[5],
parent: tokens,
attrs,
content: htmlTo[4],
children: htmlTo[4] ? tokenizerFac(htmlTo[4], undefined, inlineRules, pos + htmlTo[2].length, false) : '',
range: {
start: pos,
end: pos + len

View File

@ -84,6 +84,7 @@ export default function renderLeafBlock (block, cursor, activeBlocks, matches, u
this.tokenCache.set(text, tokens)
}
}
children = tokens.reduce((acc, token) => [...acc, ...this[snakeToCamel(token.type)](h, cursor, block, token)], [])
}

View File

@ -1,25 +0,0 @@
import { CLASS_OR_ID } from '../../../config'
import { snakeToCamel } from '../../../utils'
// `a_link`: `<a href="url">anchor</a>`
export default function aLink (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const tagClassName = className === CLASS_OR_ID['AG_HIDE'] ? className : CLASS_OR_ID['AG_HTML_TAG']
const { start, end } = token.range
const openTag = this.highlight(h, block, start, start + token.openTag.length, token)
const anchor = token.children.reduce((acc, to) => {
const chunk = this[snakeToCamel(to.type)](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, [])
const closeTag = this.highlight(h, block, end - token.closeTag.length, end, token)
return [
h(`span.${tagClassName}.${CLASS_OR_ID['AG_OUTPUT_REMOVE']}`, openTag),
h(`a.${CLASS_OR_ID['AG_A_LINK']}`, {
dataset: {
href: token.href
}
}, anchor),
h(`span.${tagClassName}.${CLASS_OR_ID['AG_OUTPUT_REMOVE']}`, closeTag)
]
}

View File

@ -7,7 +7,7 @@ export default function htmlImage (h, cursor, block, token, outerClass) {
const imageClass = CLASS_OR_ID['AG_IMAGE_MARKED_TEXT']
const { start, end } = token.range
const tag = this.highlight(h, block, start, end, token)
const { src: rawSrc, alt, width, height } = token
const { src: rawSrc, alt = '', width, height } = token.attrs
const imageInfo = getImageInfo(rawSrc)
const { src } = imageInfo
let id

View File

@ -0,0 +1,27 @@
import { CLASS_OR_ID } from '../../../config'
import { htmlToVNode } from '../snabbdom'
export default function htmlRuby (h, cursor, block, token, outerClass) {
const className = this.getClassName(outerClass, block, token, cursor)
const { children } = token
const { start, end } = token.range
const content = this.highlight(h, block, start, end, token)
const vNode = htmlToVNode(token.raw)
const previewSelector = `span.${CLASS_OR_ID['AG_RUBY_RENDER']}`
return children ? [
h(`span.${className}.${CLASS_OR_ID['AG_RUBY']}`, [
h(`span.${CLASS_OR_ID['AG_INLINE_RULE']}.${CLASS_OR_ID['AG_RUBY_TEXT']}`, content),
h(previewSelector, {
attrs: { contenteditable: 'false' }
}, vNode)
])
// if children is empty string, no need to render ruby charactors...
] : [
h(`span.${className}.${CLASS_OR_ID['AG_RUBY']}`, [
h(`span.${CLASS_OR_ID['AG_INLINE_RULE']}.${CLASS_OR_ID['AG_RUBY_TEXT']}`, content)
])
]
}

View File

@ -1,11 +1,66 @@
import { CLASS_OR_ID } from '../../../config'
import { CLASS_OR_ID, BLOCK_TYPE6 } from '../../../config'
import { snakeToCamel } from '../../../utils'
export default function htmlTag (h, cursor, block, token, outerClass) {
const className = CLASS_OR_ID['AG_HTML_TAG']
const { tag, openTag, closeTag, children, attrs } = token
const className = children ? this.getClassName(outerClass, block, token, cursor) : CLASS_OR_ID['AG_GRAY']
const tagClassName = className === CLASS_OR_ID['AG_HIDE'] ? className : CLASS_OR_ID['AG_HTML_TAG']
const { start, end } = token.range
const tag = this.highlight(h, block, start, end, token)
const isBr = /<br(?=\s|\/|>)/.test(token.tag)
return [
h(`span.${className}`, isBr ? [...tag, h('br')] : tag)
]
const openContent = this.highlight(h, block, start, start + openTag.length, token)
const closeContent = closeTag
? this.highlight(h, block, end - closeTag.length, end, token)
: ''
const anchor = Array.isArray(children) && tag !== 'ruby' // important
? children.reduce((acc, to) => {
const chunk = this[snakeToCamel(to.type)](h, cursor, block, to, className)
return Array.isArray(chunk) ? [...acc, ...chunk] : [...acc, chunk]
}, [])
: ''
switch (tag) {
case 'img': {
return this.htmlImage(h, cursor, block, token, outerClass)
}
case 'br': {
return [h(`span.${CLASS_OR_ID['AG_HTML_TAG']}`, [...openContent, h(tag)])]
}
default:
// handle void html tag
if (!closeTag) {
return [h(`span.${CLASS_OR_ID['AG_HTML_TAG']}`, openContent)]
} else if (tag === 'ruby') {
return this.htmlRuby(h, cursor, block, token, outerClass)
} else {
// if tag is a block level element, use a inline element `span` to instead.
// Because we can not nest a block level element in span element(line is span element)
// we also recommand user not use block level element in paragraph. use block element in html block.
let selector = BLOCK_TYPE6.includes(tag) ? 'span' : tag
selector += `.${CLASS_OR_ID['AG_INLINE_RULE']}`
const data = {
attrs: {},
dataset: {}
}
if (attrs.id) {
selector += `#${attrs.id}`
}
if (attrs.class && /\S/.test(attrs.class)) {
const classNames = attrs.class.split(/\s+/)
for (const className of classNames) {
selector += `.${className}`
}
}
for (const attr of Object.keys(attrs)) {
if (attr !== 'id' && attr !== 'class') {
data.attrs[attr] = attrs[attr]
}
}
return [
h(`span.${tagClassName}.${CLASS_OR_ID['AG_OUTPUT_REMOVE']}`, openContent),
h(`${selector}`, data, anchor),
h(`span.${tagClassName}.${CLASS_OR_ID['AG_OUTPUT_REMOVE']}`, closeContent)
]
}
}
}

View File

@ -9,7 +9,6 @@ import tailHeader from './tailHeader'
import hardLineBreak from './hardLineBreak'
import codeFense from './codeFense'
import inlineMath from './inlineMath'
import aLink from './aLink'
import autoLink from './autoLink'
import loadImageAsync from './loadImageAsync'
import htmlImage from './htmlImage'
@ -24,6 +23,7 @@ import strong from './strong'
import htmlEscape from './htmlEscape'
import multipleMath from './multipleMath'
import referenceDefinition from './referenceDefinition'
import htmlRuby from './htmlRuby'
import referenceLink from './referenceLink'
import referenceImage from './referenceImage'
@ -39,7 +39,6 @@ export default {
hardLineBreak,
codeFense,
inlineMath,
aLink,
autoLink,
loadImageAsync,
htmlImage,
@ -54,6 +53,7 @@ export default {
htmlEscape,
multipleMath,
referenceDefinition,
htmlRuby,
referenceLink,
referenceImage
}

View File

@ -23,9 +23,7 @@ export const inlineRules = {
'reference_link': /^\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/,
'reference_image': /^\!\[([^\]]+?)(\\*)\](?:\[([^\]]*?)(\\*)\])?/,
'tail_header': /^(\s{1,}#{1,})(\s*)$/,
'a_link': /^(<a[\s\S]*href\s*=\s*("|')(.+?)\2(?=\s|>)[\s\S]*(?!\\)>)([\s\S]*)(<\/a>)/, // can nest
'html_image': /^(<img\s([\s\S]*?src[\s\S]+?)(?!\\)>)/,
'html_tag': /^(<!--[\s\S]*?-->|<\/?[a-zA-Z\d-]+[\s\S]*?(?!\\)>)/,
'html_tag': /^(<!--[\s\S]*?-->|(<([a-zA-Z]{1}[a-zA-Z\d-]*) *[_\.\-/:a-zA-Z\d='"; ]* *(?:\/)?>)(?:([\s\S]*?)(<\/\3 *>))?)/, // row html
'html_escape': new RegExp(`^(${escapeCharacters.join('|')})`, 'i'),
'hard_line_break': /^(\s{2,})$/,

View File

@ -420,10 +420,10 @@ class Selection {
let count = 0
for (i = 0; i < len; i++) {
const child = childNodes[i]
if (count + getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'] ]).length >= offset) {
if (count + getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length >= offset) {
return getNodeAndOffset(child, offset - count)
} else {
count += getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'] ]).length
count += getTextContent(child, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
}
return { node, offset }
@ -471,7 +471,7 @@ class Selection {
do {
preSibling = preSibling.previousSibling
if (preSibling) {
offset += getTextContent(preSibling, [ CLASS_OR_ID['AG_MATH_RENDER'] ]).length
offset += getTextContent(preSibling, [ CLASS_OR_ID['AG_MATH_RENDER'], CLASS_OR_ID['AG_RUBY_RENDER'] ]).length
}
} while (preSibling)
return (node === paragraph || node.parentNode === paragraph)

View File

@ -26,7 +26,8 @@
color: var(--editorColor);
}
.ag-list-picker .active, .ag-list-picker .item:hover {
.ag-list-picker .item:hover,
.ag-list-picker .item.active {
background-color: var(--floatHoverColor);
}

View File

@ -112,6 +112,17 @@ pre.ag-paragraph {
font-size: 14px;
}
kbd {
color: var(--editorColor);
background: var(--floatBgColor);
border: 1px solid var(--floatBorderColor);
border-radius: 4px;
display: inline-block;
transform: scale(.8);
padding: 0px 5px;
box-shadow: inset 0 -1px 0 var(--floatBorderColor);
}
@media not print {
#ag-editor-id {
@ -414,19 +425,22 @@ pre[class*="language-"] {
color: var(--editorColor50);
}
.ag-hide.ag-ruby > .ag-ruby-render,
.ag-hide.ag-math > .ag-math-render {
color: var(--editorColor);
}
blockquote .ag-hide.ag-ruby > .ag-ruby-render,
blockquote .ag-hide.ag-math > .ag-math-render {
color: var(--editorColor50);
}
.ag-gray.ag-ruby > .ag-ruby-render,
.ag-gray.ag-math > .ag-math-render {
color: var(--editorColor);
background: var(--floatBgColor);
box-shadow: 0 4px 8px 0 var(--floatBorderColor);
}
.ag-gray.ag-ruby > .ag-ruby-render::before,
.ag-gray.ag-math > .ag-math-render::before {
border-bottom-color: var(--floatBgColor);
}
@ -458,7 +472,7 @@ body.dark .ag-image-marked-text.ag-image-fail::before {
}
.ag-front-icon {
fill: var(--editorColor50);
fill: var(--editorColor30);
}
.ag-front-icon::before {