(add) note embed page

This commit is contained in:
kakkokari-gtyih 2023-04-09 18:02:03 +09:00
parent e1f00e613d
commit 10fbea1224
13 changed files with 860 additions and 108 deletions

View File

@ -489,7 +489,7 @@ export class ClientServerService {
// Note Embed
fastify.get<{ Params: { note: string; } }>('/notes/:note/embed', async (request, reply) => {
vary(reply.raw, 'Accept');
reply.removeHeader('X-Frame-Options');
const note = await this.notesRepository.findOneBy({
id: request.params.note,

View File

@ -11,7 +11,7 @@ html {
width: 100vw;
height: 100vh;
cursor: wait;
background-color: var(--bg);
background-color: var(--bg, #fff);
opacity: 1;
transition: opacity 0.5s ease;
}

View File

@ -4,16 +4,19 @@ block vars
- const user = note.user;
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
- const url = `${config.url}/notes/${note.id}`;
- const userUrl = (user) => user.host ? `${config.url}/@${user.username}@${user.host}` : `${config.url}/@${user.username}`;
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
- const displayUser = isRenote ? note.renote.user: note.user;
- const mainNote = isRenote ? note.renote : note;
- const displayMedia = mainNote.files.filter((item) => item.type.startsWith('image') || item.type.startsWith('video')).slice(0, 4);
- const noteEmoji = Object.entries(note.emojis || {}).map((e) => { return { name: e[0], url: e[1], host: note.user.host }; }).concat(Object.entries(note.renote?.emojis || {}).map((e) => { return { name: e[0], url: e[1], host: note.renote?.user.host }; }))
block meta
if !user.host
link(rel='alternate' href=url type='application/activity+json')
if note.uri
link(rel='alternate' href=note.uri type='application/activity+json')
script(type='application/json') !{JSON.stringify(note)}
script(id='remote_custom_emojis' type='application/json') !{JSON.stringify(noteEmoji)}
block content
div#note
@ -22,22 +25,24 @@ block content
div.wrapper
if isRenote
div#renote
a.avatar(href=`${config.url}/@${note.user.username}` target="_blank" rel="noopener noreferrer")
a.avatar(href=userUrl(note.user) target="_blank" rel="noopener noreferrer")
img(src=note.user.avatarUrl)
i.ti.ti-repeat
span(data-mi-i18n='renotedBy' data-mi-i18n-ctx=`{"user": "${note.user.name || note.user.username}"}`)
a(href=`${config.url}/@${note.user.username}` target="_blank" rel="noopener noreferrer")
a(href=userUrl(note.user) target="_blank" rel="noopener noreferrer")
b(data-mi-i18n-target='user')
div.author
a.avatar(href=`${config.url}/@${displayUser.username}` target="_blank" rel="noopener noreferrer")
a.avatar(href=userUrl(displayUser) target="_blank" rel="noopener noreferrer")
img(src=displayUser.avatarUrl)
div.user-info
a.user-name(href=`${config.url}/@${displayUser.username}` target="_blank" rel="noopener noreferrer") #{displayUser.name || displayUser.username}
a.user-name(href=userUrl(displayUser) target="_blank" rel="noopener noreferrer") #{displayUser.name || displayUser.username}
div.user-id @#{displayUser.username}
if displayUser.host
span(style="opacity: .5;") @#{displayUser.host}
div#instance-info
a.click-anime(href=config.url target='_blank')
@ -45,9 +50,77 @@ block content
span.sr-only(data-mi-i18n='aboutX' data-mi-i18n-ctx=`{"x": "${instanceName}"}`)
main
div.mfm !{isRenote ? note.renote.text : note.text}
if (mainNote.cw != null)
div.cw-meta
span.mfm.cw-summary #{mainNote.cw}
button._button#cw-button
span#cw-info-show
b(data-mi-i18n="showMore")
span.cw-info-wrapper
if(mainNote.text)
span.cw-info(data-mi-i18n='_cw.chars' data-mi-i18n-ctx=`{"count": ${mainNote.text.length}}`)
if(mainNote.files && mainNote.files.length !== 0)
span.cw-info(data-mi-i18n='_cw.files' data-mi-i18n-ctx=`{"count": ${mainNote.files.length}}`)
if(mainNote.poll != null)
span.cw-info(data-mi-i18n='poll')
span.hide#cw-info-hide
b(data-mi-i18n="hide")
div#note-body.mfm.hide #{mainNote.text}
else
div#note-body.mfm #{mainNote.text}
if (!isRenote && note.renote)
div#quote.mfm !{note.renote.text}
hr
pre(style='white-space: pre-wrap;') !{JSON.stringify(note, null, 2)}
div#quote
div.quote-avatar
a.avatar(href=userUrl(note.renote.user) target="_blank" rel="noopener noreferrer")
img(src=note.renote.user.avatarUrl)
div.quote-body
div.meta
div.author
a(href=userUrl(note.renote.user) target="_blank" rel="noopener noreferrer")
b #{note.renote.user.name || note.renote.user.username}
span @#{note.renote.user.username}
if note.renote.user.host
span(style="opacity: .5;") @#{note.renote.user.host}
a.time(href=`${config.url}/notes/${note.renote.id}` target="_blank" rel="noopener noreferrer")
time.locale-string(datetime=note.renote.createdAt data-mi-date-mode="relative")
if (note.renote.cw != null)
div.cw-meta
span.mfm.cw-summary #{note.renote.cw}
button._button#quote-cw-button
span#quote-cw-info-show
b(data-mi-i18n="showMore")
span.cw-info-wrapper
if(mainNote.text)
span.cw-info(data-mi-i18n='_cw.chars' data-mi-i18n-ctx=`{"count": ${mainNote.text.length}}`)
if(mainNote.files && mainNote.files.length !== 0)
span.cw-info(data-mi-i18n='_cw.files' data-mi-i18n-ctx=`{"count": ${mainNote.files.length}}`)
if(mainNote.poll != null)
span.cw-info(data-mi-i18n='poll')
span.hide#quote-cw-info-hide
b(data-mi-i18n="hide")
div#quote-note-body.mfm.hide #{mainNote.text}
else
div#quote-note-body.mfm #{note.renote.text}
footer
div.info
a(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer")
time.locale-string(datetime=mainNote.createdAt data-mi-date-mode="detailed")
div.reactions
each val, key in mainNote.reactions
span.reaction-item
span.emoji #{key}
span.count #{val}
a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer")
i.ti.ti-arrow-back-up
a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer")
i.ti.ti-repeat
a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer")
i.ti.ti-plus
a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer")
i.ti.ti-dots

View File

@ -0,0 +1,9 @@
# Misskey 埋め込みのカスタマイズについて
URLパラメータで、埋め込みの形式をカスタマイズできます。
## 共通
- `rounded` … 外枠を丸める。`0`で無効(デフォルト…`1`
- `theme` … プリインストールテーマのID、もしくは `light` `dark` を指定可能。指定なしの場合は、最後に使用したテーマもしくはデバイスのカラーモードに応じたデフォルトテーマを適用。(デフォルト:指定なし)
- `mfm``animated` を指定可能。指定すると、動きのあるMFMが有効になります。デフォルト…指定なし

View File

@ -16,18 +16,171 @@ html,body {
width: auto;
}
.custom-emoji {
.hide {
display: none;
}
.emoji {
height: 2em;
width: auto;
display: inline-block;
display: inline;
vertical-align: center;
transition: transform .2s ease;
&.custom-emoji>img,svg {
height: 2em;
vertical-align: middle;
}
&.unicode-emoji>img,svg {
height: 1.25em;
vertical-align: -.25em;
}
&:hover {
transform: scale(1.2);
}
}
.mfm {
white-space: pre-wrap;
._mfm_blur_ {
filter: blur(6px);
transition: filter 0.3s;
&:hover {
filter: blur(0px);
}
}
.mfm-x2 {
--mfm-zoom-size: 200%;
}
.mfm-x3 {
--mfm-zoom-size: 400%;
}
.mfm-x4 {
--mfm-zoom-size: 600%;
}
.mfm-x2, .mfm-x3, .mfm-x4 {
font-size: var(--mfm-zoom-size);
.mfm-x2, .mfm-x3, .mfm-x4 {
/* only half effective */
font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
.mfm-x2, .mfm-x3, .mfm-x4 {
/* disabled */
font-size: 100%;
}
}
}
@keyframes mfm-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes mfm-spinX {
0% { transform: perspective(128px) rotateX(0deg); }
100% { transform: perspective(128px) rotateX(360deg); }
}
@keyframes mfm-spinY {
0% { transform: perspective(128px) rotateY(0deg); }
100% { transform: perspective(128px) rotateY(360deg); }
}
@keyframes mfm-jump {
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
@keyframes mfm-bounce {
0% { transform: translateY(0) scale(1, 1); }
25% { transform: translateY(-16px) scale(1, 1); }
50% { transform: translateY(0) scale(1, 1); }
75% { transform: translateY(0) scale(1.5, 0.75); }
100% { transform: translateY(0) scale(1, 1); }
}
// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
// let css = '';
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
@keyframes mfm-twitch {
0% { transform: translate(7px, -2px) }
5% { transform: translate(-3px, 1px) }
10% { transform: translate(-7px, -1px) }
15% { transform: translate(0px, -1px) }
20% { transform: translate(-8px, 6px) }
25% { transform: translate(-4px, -3px) }
30% { transform: translate(-4px, -6px) }
35% { transform: translate(-8px, -8px) }
40% { transform: translate(4px, 6px) }
45% { transform: translate(-3px, 1px) }
50% { transform: translate(2px, -10px) }
55% { transform: translate(-7px, 0px) }
60% { transform: translate(-2px, 4px) }
65% { transform: translate(3px, -8px) }
70% { transform: translate(6px, 7px) }
75% { transform: translate(-7px, -2px) }
80% { transform: translate(-7px, -8px) }
85% { transform: translate(9px, 3px) }
90% { transform: translate(-3px, -2px) }
95% { transform: translate(-10px, 2px) }
100% { transform: translate(-2px, -6px) }
}
// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
// let css = '';
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
@keyframes mfm-shake {
0% { transform: translate(-3px, -1px) rotate(-8deg) }
5% { transform: translate(0px, -1px) rotate(-10deg) }
10% { transform: translate(1px, -3px) rotate(0deg) }
15% { transform: translate(1px, 1px) rotate(11deg) }
20% { transform: translate(-2px, 1px) rotate(1deg) }
25% { transform: translate(-1px, -2px) rotate(-2deg) }
30% { transform: translate(-1px, 2px) rotate(-3deg) }
35% { transform: translate(2px, 1px) rotate(6deg) }
40% { transform: translate(-2px, -3px) rotate(-9deg) }
45% { transform: translate(0px, -1px) rotate(-12deg) }
50% { transform: translate(1px, 2px) rotate(10deg) }
55% { transform: translate(0px, -3px) rotate(8deg) }
60% { transform: translate(1px, -1px) rotate(8deg) }
65% { transform: translate(0px, -1px) rotate(-7deg) }
70% { transform: translate(-1px, -3px) rotate(6deg) }
75% { transform: translate(0px, -2px) rotate(4deg) }
80% { transform: translate(-2px, -1px) rotate(3deg) }
85% { transform: translate(1px, -3px) rotate(-10deg) }
90% { transform: translate(1px, 0px) rotate(3deg) }
95% { transform: translate(-2px, 0px) rotate(-3deg) }
100% { transform: translate(2px, 1px) rotate(2deg) }
}
@keyframes mfm-rubberBand {
from { transform: scale3d(1, 1, 1); }
30% { transform: scale3d(1.25, 0.75, 1); }
40% { transform: scale3d(0.75, 1.25, 1); }
50% { transform: scale3d(1.15, 0.85, 1); }
65% { transform: scale3d(0.95, 1.05, 1); }
75% { transform: scale3d(1.05, 0.95, 1); }
to { transform: scale3d(1, 1, 1); }
}
@keyframes mfm-rainbow {
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
}
}
#error {
padding: 0 0 3.5rem;
text-align: center;
@ -52,74 +205,4 @@ html,body {
p {
}
}
#note {
display: block;
position: relative;
header {
display: flex;
margin-bottom: 1rem;
.wrapper {
#renote {
display: flex;
align-items: center;
margin-bottom: .5rem;
color: var(--renote);
>.avatar {
display: block;
margin-right: 1rem;
>img {
width: 28px;
height: 28px;
border-radius: 50%;
}
}
}
.author {
display: flex;
align-items: center;
>.avatar {
display: block;
margin-right: 1rem;
>img {
width: 54px;
height: 54px;
border-radius: 50%;
}
}
>.user-info {
>.user-name {
font-weight: 700;
font-size: 1.1rem;
}
}
}
}
#instance-info {
flex-shrink: 0;
margin-left: auto;
}
}
main {
font-size: 1.05rem;
#quote {
font-size: .95rem;
padding: .75rem;
margin: 1rem 0;
border-radius: var(--radius);
border: dashed 1px var(--renote);
}
}
}

View File

@ -1,15 +1,25 @@
import JSON5 from 'json5';
import { miLocalStorage } from '@/local-storage';
import { version, lang, updateLocale } from '@/config';
import { updateI18n } from '@/i18n';
import { version, lang, updateLocale, url } from '@/config';
import { embedInitI18n } from './scripts/embed-i18n';
import '@/style.scss';
import './embed.scss';
import 'iframe-resizer/js/iframeResizer.contentWindow';
import { embedInitLinkAnime } from './scripts/link-anime';
import { parseMfm } from './scripts/parse-mfm';
import { renderNotFound } from './scripts/render-not-found';
import { parseEmoji } from './scripts/parse-emoji';
import { applyTheme } from './scripts/theme';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
console.info(`Misskey (Embed Sandbox) v${version}`);
const supportedEmbedEntity: string[] = [
'notes'
];
if (_DEV_) {
console.warn('Development mode!!!');
@ -47,7 +57,7 @@ if (localeOutdated) {
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
embedInitI18n();
}
}
//#endregion
@ -60,23 +70,85 @@ const html = document.documentElement;
html.setAttribute('lang', lang);
//#endregion
embedInitI18n();
//#region ページのパスをパース
// パス構造: /{entityName}/{id}/embed
const path = location.pathname;
if (!path.includes('/embed')) {
location.href = url;
throw new Error('Embed script was loaded on non-embed page. Force redirect to the top page.');
}
const pageMetaValues:string[] = path.split('/').filter((e) => e != '' && e != 'embed');
const pageMeta: { entityName: string; id: string; } = {
entityName: pageMetaValues[0],
id: pageMetaValues[1],
};
const URLParams = new URLSearchParams(location.search);
const enableAnimatedMfm = URLParams.get("mfm") === 'animated';
const disableRootRound = URLParams.get("rounded") === "0";
//#endregion
document.querySelectorAll(".mfm").forEach((e) => {
e.innerHTML = parseMfm(e.innerHTML).outerHTML;
});
const rootEl = document.getElementById("container");
if (disableRootRound && rootEl) {
rootEl.style.borderRadius = "0";
}
//#region ロード画面解除
const splash = document.getElementById('splash');
// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
if (splash) splash.addEventListener('transitionend', () => {
splash.remove();
});
//#region テーマ適用
const themePreferences = localStorage.getItem('theme');
const getTheme = async () => {
// テーマが指定されている場合
if (URLParams.has("theme")) {
switch (URLParams.get("theme")) {
case 'light':
return lightTheme;
case 'dark':
return darkTheme;
default:
return await import(`../themes/${URLParams.get("theme")}.json5`).catch((_) => {
return isDeviceDarkmode() ? darkTheme : lightTheme;
});
}
} else {
return isDeviceDarkmode() ? darkTheme : lightTheme;
}
};
if (splash) {
splash.style.opacity = '0';
splash.style.pointerEvents = 'none';
if (!themePreferences || URLParams.has("theme")) {
applyTheme(await getTheme(), false);
}
//#endregion
embedInitLinkAnime();
//埋め込みページごとのスクリプト読み込み
if (!supportedEmbedEntity.includes(pageMeta.entityName)) {
renderNotFound();
afterPageInitialization();
} else {
import(`./pages/${pageMeta.entityName}.ts`).then(() => {
afterPageInitialization();
});
}
function afterPageInitialization() {
embedInitI18n();
//@ts-ignore
document.querySelectorAll(".mfm").forEach((e: HTMLElement) => {
e.innerHTML = parseMfm(e.innerText, enableAnimatedMfm).outerHTML;
});
parseEmoji();
//#region ロード画面解除
const splash = document.getElementById('splash');
// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
if (splash) splash.addEventListener('transitionend', () => {
splash.remove();
});
if (splash) {
splash.style.opacity = '0';
splash.style.pointerEvents = 'none';
}
//#endregion
embedInitLinkAnime();
}

View File

@ -0,0 +1,178 @@
#note {
display: block;
position: relative;
header {
display: flex;
margin-bottom: 1rem;
.wrapper {
#renote {
display: flex;
align-items: center;
margin-bottom: .5rem;
color: var(--renote);
>.avatar {
display: block;
margin-right: 1rem;
>img {
width: 28px;
height: 28px;
border-radius: 50%;
}
}
}
.author {
display: flex;
align-items: center;
>.avatar {
display: block;
margin-right: 1rem;
>img {
width: 54px;
height: 54px;
border-radius: 50%;
}
}
>.user-info {
>.user-name {
font-weight: 700;
font-size: 1.1rem;
}
}
}
}
#instance-info {
flex-shrink: 0;
margin-left: auto;
}
}
main {
#note-body {
position: relative;
font-size: 1.05rem;
overflow-wrap: break-word;
}
#quote {
font-size: .95rem;
padding: 1rem;
margin: 1rem 0;
border-radius: var(--radius);
border: dashed 1px var(--renote);
display: flex;
.quote-avatar {
margin-right: .5rem;
flex-shrink: 0;
img {
width: 48px;
height: 48px;
border-radius: 50%;
}
}
.quote-body {
flex-grow: 1;
>.meta {
display: flex;
>.author>a {
display: inline-block;
margin-right: .25rem;
}
>a.time {
margin-left: auto;
}
}
}
}
.cw-meta {
margin-bottom: .5em;
>.cw-summary>div {
display: inline-block;
margin-right: 8px;
}
#cw-button,
#quote-cw-button {
display: inline-block;
padding: .25em .5em;
font-size: 0.75em;
color: var(--cwFg);
background: var(--cwBg);
border-radius: 2px;
&:hover {
background: var(--cwHoverBg);
}
.cw-info-wrapper {
>:first-child {
margin-left: 4px;
&:before {
content: '(';
}
}
>:after {
content: ' / ';
}
>:last-child:after {
content: ')';
}
}
}
}
}
footer {
.info {
margin: 1rem 0;
opacity: .7;
}
.reactions {
.reaction-item {
border-radius: 4px;
padding: 0 6px;
margin: 2px;
height: 32px;
background-color: var(--fgOnAccent);
display: inline-block;
img {
height: 1.25rem;
vertical-align: -.25rem;
}
span {
display: inline-block;
margin-left: 4px;
line-height: 32px;
}
}
}
.button {
margin-right: 2rem;
padding: .5rem;
font-size: 1.2rem;
&:hover {
text-decoration: none;
}
}
}
}

View File

@ -0,0 +1,60 @@
import { embedI18n as i18n } from '../scripts/embed-i18n';
import { dateTimeFormat } from '@/scripts/intl-const';
import '../notes.scss';
//CWボタン
document.getElementById("cw-button")?.addEventListener("click", () => {
document.getElementById("note-body")?.classList.toggle("hide");
document.getElementById("cw-info-show")?.classList.toggle("hide");
document.getElementById("cw-info-hide")?.classList.toggle("hide");
});
document.getElementById("quote-cw-button")?.addEventListener("click", () => {
document.getElementById("quote-note-body")?.classList.toggle("hide");
document.getElementById("quote-cw-info-show")?.classList.toggle("hide");
document.getElementById("quote-cw-info-hide")?.classList.toggle("hide");
});
//時刻タイムゾーン関連がややこしいのでJSでレンダリング
document.querySelectorAll("time.locale-string").forEach((el) => {
const dateTimeRaw = el.getAttribute("datetime");
const mode = el.getAttribute("data-mi-date-mode");
if (dateTimeRaw !== null) {
const _time = new Date(dateTimeRaw).getTime();
const invalid = Number.isNaN(_time);
const absolute:string = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
let now = new Date().getTime();
const relative = () => {
if (invalid) return i18n.ts._ago.invalid;
const ago = (now - _time) / 1000/*ms*/;
return (
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) :
ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) :
ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) :
ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? i18n.ts._ago.justNow :
i18n.ts._ago.future);
};
switch (mode) {
case 'absolute':
el.innerHTML = absolute;
break;
case 'detailed':
el.innerHTML = `${absolute} (${relative()})`;
break;
case 'relative':
default:
el.innerHTML = relative();
break;
}
}
});
export { };

View File

@ -1,4 +1,5 @@
import { i18n } from "@/i18n";
import { locale } from "@/config";
import { I18n } from "@/scripts/i18n";
/**
* vueページ向け翻訳適用関数
@ -8,6 +9,10 @@ import { i18n } from "@/i18n";
* <span data-mi-i18n="翻訳key必須" data-mi-i18n-ctx=" *JSON Objectで動的な値を指定任意* "></span>
* ```
*/
const i18n = new I18n(locale);
export const embedI18n = i18n;
export function embedInitI18n() {
const els: NodeListOf<HTMLElement> = document.querySelectorAll("[data-mi-i18n]");
els.forEach((tag: HTMLElement) => {

View File

@ -0,0 +1,39 @@
import { char2twemojiFilePath } from '@/scripts/emoji-base';
const char2path = char2twemojiFilePath;
const remoteCustomEmojiEl = document.getElementById("remote_custom_emojis");
let remoteCustomEmoji: { name: string; url: string; host?: string; }[] = [];
if (remoteCustomEmojiEl) {
remoteCustomEmoji = JSON.parse(remoteCustomEmojiEl.innerHTML);
}
function getCustomEmojiName(ceNameRaw: string) {
return (ceNameRaw.startsWith(":") ? ceNameRaw.substr(1, ceNameRaw.length - 2) : ceNameRaw).replace('@.', '')
}
function getCustomEmojiUrl(ceName: string) {
const remote = remoteCustomEmoji.find((e) => e.name === ceName);
if (remote) {
return remote.url;
}
return `/emoji/${ceName}.webp`;
}
export function parseEmoji() {
document.querySelectorAll(".emoji").forEach((el) => {
let src: (string | null) = null;
if (el.innerHTML.startsWith(":")) {
console.log(getCustomEmojiName(el.innerHTML));
src = getCustomEmojiUrl(getCustomEmojiName(el.innerHTML));
} else {
src = char2path(el.innerHTML);
}
if (src) {
const emojiEl = document.createElement("img");
emojiEl.src = src;
emojiEl.title = getCustomEmojiName(el.innerHTML);
el.innerHTML = emojiEl.outerHTML;
}
});
}

View File

@ -1,6 +1,9 @@
import * as mfm from 'mfm-js';
import { toUnicode } from 'punycode';
import { host as localHost } from '@/config';
import sanitizeHtml from 'sanitize-html';
import Prism from 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
const QUOTE_STYLE = `
display: block;
@ -18,11 +21,16 @@ interface MfmFn extends mfm.MfmFn {
}
};
export function parseMfm(text: string): HTMLDivElement {
export function parseMfm(text: string, useAnim: boolean = false): HTMLDivElement {
const ast = mfm.parse(text);
const el = document.createElement("div");
const validTime = (t: string | null | undefined) => {
if (t == null) return null;
return t.match(/^[0-9.]+s$/) ? t : null;
};
function genEl(ast: (MfmFn | mfm.MfmNode)[]) {
return ast.map((token: (MfmFn | mfm.MfmNode)) => {
switch (token.type) {
@ -69,6 +77,49 @@ export function parseMfm(text: string): HTMLDivElement {
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
let style;
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : '');
break;
}
case 'jelly': {
const speed = validTime(token.props.args.speed) ?? '1s';
style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : '';
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : '';
break;
}
case 'spin': {
const direction =
token.props.args.left ? 'reverse' :
token.props.args.alternate ? 'alternate' :
'normal';
const anime =
token.props.args.x ? 'mfm-spinX' :
token.props.args.y ? 'mfm-spinY' :
'mfm-spin';
const speed = validTime(token.props.args.speed) ?? '1.5s';
style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : '';
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
break;
}
case 'flip': {
const transform =
(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
@ -122,6 +173,12 @@ export function parseMfm(text: string): HTMLDivElement {
})
return [el];
}
case 'rainbow': {
const speed = validTime(token.props.args.speed) ?? '1s';
style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
break;
}
// TODO: Sparkle の プレーンHTML実装
case 'rotate': {
const degrees = parseFloat(token.props.args.deg ?? '90');
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
@ -151,6 +208,9 @@ export function parseMfm(text: string): HTMLDivElement {
style = `background-color: #${color};`;
break;
}
default:
style = '';
break;
}
if (style == null) {
const el = document.createElement("span");
@ -240,14 +300,19 @@ export function parseMfm(text: string): HTMLDivElement {
el.style.overflowX = 'scroll';
el.style.width = '100%';
const elc = document.createElement('code');
elc.innerText = token.props.code;
const prismLang = Prism.languages[token.props.lang] ? token.props.lang : 'js';
elc.innerHTML = Prism.highlight(token.props.code, Prism.languages[prismLang], prismLang);
el.classList.add(`language-${prismLang}`);
elc.classList.add(`language-${prismLang}`);
el.appendChild(elc);
return [el];
}
case 'inlineCode': {
const el = document.createElement('code');
el.innerText = token.props.code;
const prismLang = 'js';
el.innerHTML = Prism.highlight(token.props.code, Prism.languages[prismLang], prismLang);
el.classList.add(`language-${prismLang}`);
return [el];
}
@ -262,14 +327,14 @@ export function parseMfm(text: string): HTMLDivElement {
case 'emojiCode': {
const el = document.createElement('span');
el.classList.add('custom-emoji', 'needs-replacing');
el.classList.add('emoji', 'custom-emoji');
el.innerText = `:${token.props.name}:`;
return [el];
}
case 'unicodeEmoji': {
const el = document.createElement('span');
el.classList.add('emoji');
el.classList.add('emoji', 'unicode-emoji');
el.innerText = token.props.emoji;
return [el];
}
@ -325,5 +390,11 @@ export function parseMfm(text: string): HTMLDivElement {
el.appendChild(element as HTMLElement);
});
el.innerHTML = sanitizeHtml(el.innerHTML, {
allowedAttributes: {
'*': ['style', 'class']
}
});
return el;
}

View File

@ -0,0 +1,22 @@
import { instanceName } from "@/config";
import { embedI18n as i18n } from '../scripts/embed-i18n';
export function renderNotFound() {
const el = document.getElementById("container");
if (el) {
el.innerHTML = `<div id="error">
<div>
<div id="instance-info">
<a class="click-anime" href="http://localhost:3000" target="_blank">
<img src="/static-assets/splash.png" class="_anime_bounce_standBy">
<span class="sr-only">${i18n.t('aboutX', {x: instanceName || 'Misskey'})}</span>
</a>
</div>
<img class="main" src="https://xn--931a.moe/assets/not-found.jpg">
<h2>${i18n.ts.notFound}</h2>
<p>${i18n.ts.notFoundDescription}</p>
</div>
</div>`;
}
}

View File

@ -0,0 +1,140 @@
import tinycolor from 'tinycolor2';
export type Theme = {
id: string;
name: string;
author: string;
desc?: string;
base?: 'dark' | 'light';
props: Record<string, string>;
};
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { deepClone } from '@/scripts/clone';
import { miLocalStorage } from '@/local-storage';
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const getBuiltinThemes = () => Promise.all(
[
'l-light',
'l-coffee',
'l-apricot',
'l-rainy',
'l-botanical',
'l-vivid',
'l-cherry',
'l-sushi',
'l-u0',
'd-dark',
'd-persimmon',
'd-astro',
'd-future',
'd-botanical',
'd-green-lime',
'd-green-orange',
'd-cherry',
'd-ice',
'd-u0',
].map(name => import(`../../themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
);
let timeout = null;
export function applyTheme(theme: Theme, persist = true) {
if (timeout) window.clearTimeout(timeout);
document.documentElement.classList.add('_themeChanging_');
timeout = window.setTimeout(() => {
document.documentElement.classList.remove('_themeChanging_');
}, 1000);
const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
// Deep copy
const _theme = deepClone(theme);
if (_theme.base) {
const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
if (base) _theme.props = Object.assign({}, base.props, _theme.props);
}
const props = compile(_theme);
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', props['htmlThemeColor']);
break;
}
}
for (const [k, v] of Object.entries(props)) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
}
document.documentElement.style.setProperty('color-schema', colorSchema);
if (persist) {
miLocalStorage.setItem('theme', JSON.stringify(props));
miLocalStorage.setItem('colorSchema', colorSchema);
}
}
function compile(theme: Theme): Record<string, string> {
function getColor(val: string): tinycolor.Instance {
// ref (prop)
if (val[0] === '@') {
return getColor(theme.props[val.substr(1)]);
}
// ref (const)
else if (val[0] === '$') {
return getColor(theme.props[val]);
}
// func
else if (val[0] === ':') {
const parts = val.split('<');
const func = parts.shift().substr(1);
const arg = parseFloat(parts.shift());
const color = getColor(parts.join('<'));
switch (func) {
case 'darken': return color.darken(arg);
case 'lighten': return color.lighten(arg);
case 'alpha': return color.setAlpha(arg);
case 'hue': return color.spin(arg);
case 'saturate': return color.saturate(arg);
}
}
// other case
return tinycolor(val);
}
const props = {};
for (const [k, v] of Object.entries(theme.props)) {
if (k.startsWith('$')) continue; // ignore const
props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
}
return props;
}
function genValue(c: tinycolor.Instance): string {
return c.toRgbString();
}
export function validateTheme(theme: Record<string, any>): boolean {
if (theme.id == null || typeof theme.id !== 'string') return false;
if (theme.name == null || typeof theme.name !== 'string') return false;
if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
if (theme.props == null || typeof theme.props !== 'object') return false;
return true;
}