text-bubble-component@0.2.0/parser
scrapbox-preview-box/parserをpreactで書き換えたもの
text-bubble-component@0.2.0で使用する
実装したいこと
/icons/done.iconインデントをつけられるようにする
/icons/doing.iconタイトル付きオーディオを実装する
/icons/doing.icon動画埋め込みを実装する
niconico動画の埋め込みもやってみる?
/icons/done.iconcode blockを一段下げる
1 hop linkが存在しない空リンクは色を変える
1 hop linkが存在しないことを何らかの形でviewに伝える
hooksに切り出す?
useTextBubble@0.1.0
構文解析とrenderingが混じっているのはなんかよくない
viewとlogicが入り混じっている
構文解析
workerに委託するか?
読み込み中表示とかができるようになる
そこまで解析に時間はかからないと思うのだが……
event handlerの提供
構文解析結果はcacheするか?
それをやるとページデータのfetchまでhookに含める必要が出てくる
それはこのhooksの責務ではないだろう
いや、project,titleLc,linesのセットを受け取るから、それをcacheさせればいいのか
cacheの有効期限が切れたら新しいlinesで構文解析をやり直す
構文解析は機能に含めない?
「構文解析結果をcacheする機能」と「構文解析結果を表示用に加工する機能」とで分割できる
こう分離してもいい気もする
うーん、そこまでする必要もないような
cacheはやめよう
performance tuningが必要になった時の余裕として残しておく
特定の行 or 特定の語句が含まれる行をmarkする
Componentでその行をhighlightしたりその行までscrollしたりする
既知の問題
DOMのrenderingにかなり時間がかかることがある
読み込み中表示も出せないので困った
対策:仮想DOMを経由せずに、Element.insertAdjacentHTML()などで直接挿入すれば行ける?
予めparseしたHTML文字列を保持しておく
あー、これだとevent handlerの設定が面倒になるのか。難しいな……
2021-07-06
04:45:25 FontAwesomeのversionを上げた
/takker/ScrapboxのFont Awesomeが5になった
2021-06-17
2021-06-12 <Icon>のリンク属性の設定がおかしかった
01:11:41 <Link>にてtitleとhashを分けて正しくencodeできるようにした
01:02:18 scrapbox-titleLcでページアドレスをencodeする
2021-06-11
22:43:49 text-bubble-component@0.2.0/parser#60c2372a1280f00000e2ba03を table blockにも適用した
21:52:25 /をタイトルに含むページが外部project link記法になっていた時、/を%2Fに変換していなかった
text-bubble-component@0.2.0/parser#60c35c2b1280f00000e0aa4b
ついでに[/:project/:title]の:projectをみて、リンクを新しいタブで開くかどうかを設定するように変更した
14:02:58 外部projectへのハッシュタグをクリックしたときに、新しいタブを開けてなかった
01:13:13 text-bubble-component@0.2.0/parser#60c2372a1280f00000e2ba03
2021-06-10
22:04:13 titleではなくtitleLcを受け取るようにした
titleLcしかないときに使いづらい
titleLcからtitleには変換できない
2021-05-30
00:20:17 key属性をつけてなかった
2021-05-28
10:53:36 インデントをつけられるようにした
10:46:04 <audio>が表示されていなかったのを直した
10:34:06 空行を省略しない
10:24:54 行idをつけた
同時にtext-bubble-component@0.2.0/parser#60b030981280f00000218cc8を解決した
08:42:05 リンク付き画像のCSSを直した
08:40:05 リンク付き画像にclassが当たっていなかった
リンクのrelも設定されていなかった
そもそも<tag ${condition ? 'class="link"' : ''} />という構文はhtmでは使えないっぽい
08:04:08 Upgrade parser to scrapbox-parser@7.1.0
既知の問題
/icons/done.iconcode block記法とtable記法を一行ずつ<Line>で囲めていない
<node>も含め、対応するline idを渡せるようにしたい
dependencies
scrapbox-parser@7.1.0
htm@3.0.4/preact
use-KaTeX
code:script.js
import {html} from '../htm@3.0.4%2Fpreact/script.js';
import {useState, useMemo, useCallback} from '../preact@10.5.13/hooks.js';
import {useKaTeX} from '../use-KaTeX/script.js';
import {parse} from '../scrapbox-parser@7.1.0/script.js';
import {toLc} from '../scrapbox-titleLc/script.js';
export const Content = ({lines, project, titleLc, noIndent}) => {
const blocks = useMemo(() => {
const text = lines.map(line => typeof line === 'string' ? line : line.text).join('\n');
return parse(text, {hasTitle: false});
},
lines
);
const lineIds = useMemo(
() => lines.flatMap(line => typeof line === 'string' ? [] : line.id),
lines
);
let counter = 0;
return html`${blocks.flatMap(block => {
switch (block.type) {
case 'title':
return [];
case 'codeBlock': {
const start = counter;
counter += block.content.split('\n').length + 1;
return [html`<${CodeBlock}
key="${lineIdsstart}"
block="${block}"
project="${project}" titleLc="${titleLc}"
noIndent="${noIndent}"
ids="${lineIds.slice(start, counter)}" />`];
}
case 'table': {
const start = counter;
counter += block.cells.length + 1;
return [html`<${Table}
key="${lineIdsstart}"
block="${block}"
project="${project}" titleLc="${titleLc}"
noIndent="${noIndent}"
ids="${lineIds.slice(start, counter)}" />`];
}
case 'line':
counter++;
return [html`<${Line}
key="${lineIdscounter - 1}"
index="${lineIdscounter - 1}"
indent="${block.indent}"
noIndent="${noIndent}">
${block.nodes.length > 0 ? block.nodes.map(node => html<${Node} node="${node}" project="${project}" />) : html<br />}
<//>`];
}
})}`;
};
const Line = ({index, indent, noIndent, children}) =>
html`<div class="line"
data-id="L${index}"
data-indent="${indent}"
style="margin-left: ${noIndent ? '' : ${1.0 * indent}em};"
${children}</div>`;
const CodeBlock = ({block: {fileName, content, indent}, project, titleLc, ids}) => {
const buttonLabel, setButtonLabel = useState('\uf0c5');
const handleClick = useCallback(() => {
navigator.clipboard.writeText(content);
setButtonLabel('Copied');
setTimeout(() => setButtonLabel('\uf0c5'), 1000);
}, content);
return html`
<${Line} index="${ids0}" indent="${indent}">
<span class="code-block">
<span class="code-block-start">
${fileName.includes('.') ?
html<a href="/api/code/${project}/${titleLc}/${fileName}" target="_blank">${fileName}</a> :
fileName}
</span>
<span class="copy" title="Copy" onClick="${handleClick}">${buttonLabel}</span>
</span>
<//>
${content.split('\n').map((line, index) => html`<${Line} index="${idsindex + 1}" indent="${indent}">
<code class="code-block">
${line}
</code>
<//>`)}
`;
};
const Table = ({block: {fileName, cells, indent}, project, titleLc, ids}) =>
html`
<${Line} index="${ids0}" indent="${indent}">
<span class="table-block">
<span class="table-block-start">
<a href="/api/table/${project}/${titleLc}/${fileName}.csv" target="_blank">${fileName}</a>
</span>
</span>
<//>
${cells.map((cell, i) => html`
<${Line} index="${idsi + 1}" indent="${indent}">
<span class="table-block table-block-row">
${cell.map((row, index) => html`<span class="cell col-${index}">
${row0?.text ?? ''}
</span>`)}
</span>
<//>
`)}
`;
const Node = ({node, project}) => {
switch(node.type) {
case 'code':
return html<${Code} node="${node}" />;
case 'formula':
return html<${Formula} node="${node}" />;
case 'commmandLine':
return html<${CommandLine} node="${node}" />;
case 'helpfeel':
return html<${Helpfeel} node="${node}" />;
case 'quote':
return html<${Quote} node="${node}" project="${project}" />;
case 'strong':
return html<${Strong} node="${node}" project="${project}" />;
case 'decoration':
return html<${Decoration} node="${node}" project="${project}" />;
case 'plain':
case 'blank':
return html<${Plain} node="${node}" />;
case 'hashTag':
return html<${HashTag} node="${node}" project="${project}" />;
case 'link':
return html<${Link} node="${node}" project="${project}" />;
case 'googleMap':
return html<${GoogleMap} node="${node}" />;
case 'icon':
return html<${Icon} node="${node}" project="${project}" />;
case 'strongIcon':
return html<${Icon} node="${node}" project="${project}" strong />;
case 'image':
return html<${Image} node="${node}" />;
case 'strongImage':
return html<${Image} node="${node}" strong />;
}
};
const Code = ({node: {text}}) => html<code class="code">${text}</code>;
const Formula = ({node: {formula}}) => {
const {ref, error, setFormula} = useKaTeX('');
setFormula(formula);
return html`
<span class="formula${error ? ' error' : ''}">
${error ? html<code>${formula}</code> :
html<span class="katex-display" ref="${ref}"/>}
</span>`;
}
const CommandLine = ({node: {text, symbol}}) =>
html`<code class="cli">
<span class="prefix">${symbol}</span>
${' '}
<span class="command">${text}</span>
</code>`;
const Helpfeel = ({node: {text}}) =>
html`<code class="helpfeel">
<span class="prefix">?</span>
${' '}
<span class="entry">${text}</span>
</code>`;
const Quote = ({node: {nodes}, project}) => html`
<blockquote class="quote">
${nodes.map(node => html<${Node} node="${node}" project="${project}" />)}
</blockquote>
`;
const Strong = ({node: {nodes}, project}) => html`
<strong>
${nodes.map(node => html<${Node} node="${node}" project="${project}" />)}
</strong>
`;
const Decoration = ({node: {decos, nodes}, project}) => html`
<span class="${decos.map(deco => deco-${deco}).join(' ')}">
${nodes.map(node => html<${Node} node="${node}" project="${project}" />)}
</span>
`;
const GoogleMap = ({node: {place, latitude, longitude, zoom}}) => html`
<span class="pointing-device-map">
<a href="https://www.google.com/maps/search/${place}/@${latitude},${longitude},${zoom}z"
rel="noopner noreferrer"
target="_blank">
<img class="google-map"
src="/api/google-map/static-map?center=${latitude}%2C${longitude}&markers=${place}&zoom=${zoom}&_csrf=${window._csrf}" />
</a>
</span>
`;
const Plain = ({node: {text}}) => html${text};
const Icon = ({node: {pathType, path}, strong, project: _project}) => {
const project = pathType === 'relative' ? _project : path.match(/\/(\w\-+)/)1;
const title = pathType === 'relative' ? path: path.match(/\/\w\-+\/(.+)$/)1;
const titleLc = toLc(title);
return html`
<a href="/${project}/${titleLc}"
rel="${project === scrapbox.Project.name ? 'route' : 'noopener noreferrer'}"
target="${project === scrapbox.Project.name ? '' : '_blank'}">
<img class="${strong ? 'icon strong-icon' : 'icon'}"
alt="${title}"
src="/api/pages/${project}/${titleLc}/icon">${title}</img>
</a>
`;
};
const Image = ({node: {link, src}, strong}) => {
const href = link || (/https:\/\/gyazo\.com\/^\/+\/thumb\/1000/.test(src) ?
src.slice(0, -'/thumb/1000'.length) : src);
return html`
<a class="${link ? 'link' : ''}" href="${href}" rel="noopner noreferrer" target="_blank">
<img class="${strong ? 'image strong-image' : 'image'}" src="${src}" />
</a>
`;
};
const HashTag = ({node: {href}, project}) => html`
<a href="/${project}/${toLc(href)}"
class="page-link"
type="hashTag"
rel="${project === scrapbox.Project.name ? 'route' : 'noopener noreferrer'}"
target="${project === scrapbox.Project.name ? '' : '_blank'}">
#${href}
</a>
`;
const Link = ({node: {pathType, href, content}, project}) => {
switch (pathType) {
case 'root': {
const _project, title, hash = href.match(/\/(\w\-+)\/(^#*)(.*)/) ?? '', '', '';
return html`
<a class="page-link"
type="link" href="/${_project}/${toLc(title)}${hash}"
rel="${_project === scrapbox.Project.name ? 'route' : 'noopener noreferrer'}"
target="${_project === scrapbox.Project.name ? '' : '_blank'}">
${href}
</a>`;
}
case 'relative': {
const title, hash = href.match(/^(^#*)(.*)/) ?? '', '';
return html`
<a class="page-link"
type="link" href="/${project}/${toLc(title)}${hash}"
rel="${project === scrapbox.Project.name ? 'route' : 'noopener noreferrer'}"
target="${project === scrapbox.Project.name ? '' : '_blank'}">
${href}
</a>`;
}
case 'absolute':
// コードが適当なのは後で直す
// 動画と画像の識別を行う
const url = new URL(href);
switch (url.hostname) {
case 'www.youtube.com':
return html`<iframe
src="https://www.youtube.com/embed/${url.searchParams.get('v')}?autoplay=0&start=0"
type="text/html"
allowfullscreen='' />`;
case 'vimeo.com':
return html`<iframe
src="https://player.vimeo.com/video/${url.pathname.slice(1)}"
type="text/html"
allowfullscreen='' />`;
default:
break;
}
if (/\.(?:mp3|ogg|wav)/.test(href) ?? content === '') {
return html<audio preload='none' src="${href}" controls />;
}
return html`<a class="link" href="${href}" rel="noopener noreferrer" target="_blank">
${
// contentが空のときはundefinedではなく''になるので、
// ??ではなく||でfallback処理をする必要がある
content || href
}
</a>`;
}
};
css
code:script.js
export const CSS = `
a {
background-color: transparent;
text-decoration: none;
cursor: pointer;
}
img {
display: inline-block;
max-width: 100%;
max-height: 100px;
}
code {
font-family: var(--code-text-font, Menlo, Monaco, Consolas, "Courier New", monospace);
font-size: 90%;
color: var(--code-color, #342d9c);
background-color: var(--code-bg, rgba(0,0,0,0.04));
padding: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
blockquote {
background-color: var(--quote-bg-color, rgba(0,0,0,0.05));
display: block;
border-left: solid 4px #a0a0a0;
padding-left: 4px;
margin: 0px;
}
strong {
font-weight: bold;
}
iframe {
display: inline-block;
margin: 3px 0;
vertical-align: middle;
max-width: 100%;
width: 640px;
height: 360px;
border: 0;
}
audio {
display: inline-block;
vertical-align: middle;
white-space: initial;
max-width: 100%;
}
.formula {
margin: auto 6px;
}
.formula.error code {color:#fd7373; }
.katex-display {
display: inline-block !important;
margin: 0 !important;
text-align: inherit !important;
}
.error .katex-display {
display: none;
}
.cli {
border-radius: 4px;
}
.cli .prefix {
color: #9c6248;
}
.helpfeel {
background-color: #fbebdd;
border-radius: 4px;
padding: 3px !important;
}
.helpfeel .prefix {
color: #f17c00;
}
.helpfeel .entry {
color: #cc5020;
}
.code-block {
display: block;
line-height: 1.7em;
background-color: var(--code-bg, rgba(0,0,0,0.04));
}
.code-block-start {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
color: #342d9c;
background-color: #ffcfc6;
font-size: .9em;
padding: 1px 2px;
}
.code-block-start a {
color: #342d9c;
text-decoration: underline;
}
code.code-block,
.table-block.table-block-row {
padding-left: 1.0em;
}
.copy {
font-family: "Font Awesome 5 Free";
cursor: pointer;
}
.table-block {
white-space: nowrap;
}
.table-block-start {
padding: 1px 2px;
font-size: .9em;
background-color: #ffcfc6;
}
.table-block-start a {
color: #342d9c;
text-decoration: underline;
}
.cell {
margin: 0;
padding: 0 2px 0 8px;
box-sizing: content-box;
display: inline-block;
white-space: pre;
}
.cell:nth-child(2n+1) {
background-color: rgba(0,0,0,0.04);
}
.cell:nth-child(2n) {
background-color: rgba(0,0,0,0.06);
}
.strong-image {
max-height: 100%;
}
.icon {
height: 11px;
vertical-align: middle;
}
.strong-icon {
height: calc(11px * 1.2);
}
.deco-\\/ {font-style: italic;}
.deco-\\*-1 {font-weight: bold;}
.deco-\\*-2 {font-weight: bold; font-size: 1.20em;}
.deco-\\*-3 {font-weight: bold; font-size: 1.44em;}
.deco-\\*-4 {font-weight: bold; font-size: 1.73em;}
.deco-\\*-5 {font-weight: bold; font-size: 2.07em;}
.deco-\\*-6 {font-weight: bold; font-size: 2.49em;}
.deco-\\*-7 {font-weight: bold; font-size: 3.00em;}
.deco-\\*-8 {font-weight: bold; font-size: 3.58em;}
.deco-\\*-9 {font-weight: bold; font-size: 4.30em;}
.deco-\\*-10 {font-weight: bold; font-size: 5.16em;}
.deco-\\- {text-decoration: line-through;}
.deco-_ {text-decoration: underline;}
.page-link {color: var(--page-link-color, #5e8af7);}
.page-link:hover {color: var(--page-link-hover-color, #2d67f5);}
.empty-page-link {color: :var(--empty-page-link-color, #fd7373);}
.empty-page-link:hover {color: :var(--empty-page-link-hover-color, #fd7373);}
.link {
color: var(--page-link-color, #5e8af7);
text-decoration: underline;
}
.link:hover {color: var(--page-link-color-hover-color, #2d67f5);}
.link img {
padding-bottom: 3px;
border-style: none none solid;
border-width: 1.5px;
border-color: #8fadf9;
}
.permalink {
background-color: var(--line-permalink-color, rgba(234,218,74,0.75));
}
`;