Skip to content
Snippets Groups Projects
Commit 7da60a01 authored by Akihiko Odaki's avatar Akihiko Odaki
Browse files

Store texts as HTML

parent 8c414329
No related branches found
No related tags found
No related merge requests found
Showing
with 293 additions and 269 deletions
......@@ -4,7 +4,7 @@ import signin from './signin.vue';
import signup from './signup.vue';
import forkit from './forkit.vue';
import nav from './nav.vue';
import postHtml from './post-html';
import postHtml from './post-html.vue';
import poll from './poll.vue';
import pollEditor from './poll-editor.vue';
import reactionIcon from './reaction-icon.vue';
......
......@@ -4,13 +4,13 @@
<img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/>
</router-link>
<div class="content">
<div class="balloon" :data-no-text="message.text == null">
<div class="balloon" :data-no-text="message.textHtml == null">
<p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p>
<button class="delete-button" v-if="isMe" title="%i18n:common.delete%">
<img src="/assets/desktop/messaging/delete.png" alt="Delete"/>
</button>
<div class="content" v-if="!message.isDeleted">
<mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/>
<mk-post-html class="text" v-if="message.textHtml" ref="text" :html="message.textHtml" :i="os.i"/>
<div class="file" v-if="message.file">
<a :href="message.file.url" target="_blank" :title="message.file.name">
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
......@@ -38,21 +38,32 @@ import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
props: ['message'],
data() {
return {
urls: []
};
},
computed: {
acct() {
return getAcct(this.message.user);
},
isMe(): boolean {
return this.message.userId == (this as any).os.i.id;
},
urls(): string[] {
if (this.message.ast) {
return this.message.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
watch: {
message: {
handler(newMessage, oldMessage) {
if (!oldMessage || newMessage.textHtml !== oldMessage.textHtml) {
this.$nextTick(() => {
const elements = this.$refs.text.$el.getElementsByTagName('a');
this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
.map(({ href }) => href);
});
}
},
immediate: true
}
}
});
......
import Vue from 'vue';
import * as emojilib from 'emojilib';
import getAcct from '../../../../../common/user/get-acct';
import { url } from '../../../config';
import MkUrl from './url.vue';
const flatten = list => list.reduce(
(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []
);
export default Vue.component('mk-post-html', {
props: {
ast: {
type: Array,
required: true
},
shouldBreak: {
type: Boolean,
default: true
},
i: {
type: Object,
default: null
}
},
render(createElement) {
const els = flatten((this as any).ast.map(token => {
switch (token.type) {
case 'text':
const text = token.content.replace(/(\r\n|\n|\r)/g, '\n');
if ((this as any).shouldBreak) {
const x = text.split('\n')
.map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]);
x[x.length - 1].pop();
return x;
} else {
return createElement('span', text.replace(/\n/g, ' '));
}
case 'bold':
return createElement('strong', token.bold);
case 'url':
return createElement(MkUrl, {
props: {
url: token.content,
target: '_blank'
}
});
case 'link':
return createElement('a', {
attrs: {
class: 'link',
href: token.url,
target: '_blank',
title: token.url
}
}, token.title);
case 'mention':
return (createElement as any)('a', {
attrs: {
href: `${url}/@${getAcct(token)}`,
target: '_blank',
dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token)
},
directives: [{
name: 'user-preview',
value: token.content
}]
}, token.content);
case 'hashtag':
return createElement('a', {
attrs: {
href: `${url}/search?q=${token.content}`,
target: '_blank'
}
}, token.content);
case 'code':
return createElement('pre', [
createElement('code', {
domProps: {
innerHTML: token.html
}
})
]);
case 'inline-code':
return createElement('code', {
domProps: {
innerHTML: token.html
}
});
case 'quote':
const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n');
if ((this as any).shouldBreak) {
const x = text2.split('\n')
.map(t => [createElement('span', t), createElement('br')]);
x[x.length - 1].pop();
return createElement('div', {
attrs: {
class: 'quote'
}
}, x);
} else {
return createElement('span', {
attrs: {
class: 'quote'
}
}, text2.replace(/\n/g, ' '));
}
case 'emoji':
const emoji = emojilib.lib[token.emoji];
return createElement('span', emoji ? emoji.char : token.content);
default:
console.log('unknown ast type:', token.type);
}
}));
const _els = [];
els.forEach((el, i) => {
if (el.tag == 'br') {
if (els[i - 1].tag != 'div') {
_els.push(el);
}
} else {
_els.push(el);
}
});
return createElement('span', _els);
}
});
<template><div class="mk-post-html" v-html="html"></div></template>
<script lang="ts">
import Vue from 'vue';
import getAcct from '../../../../../common/user/get-acct';
import { url } from '../../../config';
function markUrl(a) {
while (a.firstChild) {
a.removeChild(a.firstChild);
}
const schema = document.createElement('span');
const delimiter = document.createTextNode('//');
const host = document.createElement('span');
const pathname = document.createElement('span');
const query = document.createElement('span');
const hash = document.createElement('span');
schema.className = 'schema';
schema.textContent = a.protocol;
host.className = 'host';
host.textContent = a.host;
pathname.className = 'pathname';
pathname.textContent = a.pathname;
query.className = 'query';
query.textContent = a.search;
hash.className = 'hash';
hash.textContent = a.hash;
a.appendChild(schema);
a.appendChild(delimiter);
a.appendChild(host);
a.appendChild(pathname);
a.appendChild(query);
a.appendChild(hash);
}
function markMe(me, a) {
a.setAttribute("data-is-me", me && `${url}/@${getAcct(me)}` == a.href);
}
function markTarget(a) {
a.setAttribute("target", "_blank");
}
export default Vue.component('mk-post-html', {
props: {
html: {
type: String,
required: true
},
i: {
type: Object,
default: null
}
},
watch {
html: {
handler() {
this.$nextTick(() => [].forEach.call(this.$el.getElementsByTagName('a'), a => {
if (a.href === a.textContent) {
markUrl(a);
} else {
markMe((this as any).i, a);
}
markTarget(a);
}));
},
immediate: true,
}
}
});
</script>
<style lang="stylus">
.mk-post-html
a
word-break break-all
> .schema
opacity 0.5
> .host
font-weight bold
> .pathname
opacity 0.8
> .query
opacity 0.5
> .hash
font-style italic
p
margin 0
</style>
<template>
<a class="mk-url" :href="url" :target="target">
<span class="schema">{{ schema }}//</span>
<span class="hostname">{{ hostname }}</span>
<span class="port" v-if="port != ''">:{{ port }}</span>
<span class="pathname" v-if="pathname != ''">{{ pathname }}</span>
<span class="query">{{ query }}</span>
<span class="hash">{{ hash }}</span>
%fa:external-link-square-alt%
</a>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['url', 'target'],
data() {
return {
schema: null,
hostname: null,
port: null,
pathname: null,
query: null,
hash: null
};
},
created() {
const url = new URL(this.url);
this.schema = url.protocol;
this.hostname = url.hostname;
this.port = url.port;
this.pathname = url.pathname;
this.query = url.search;
this.hash = url.hash;
}
});
</script>
<style lang="stylus" scoped>
.mk-url
word-break break-all
> [data-fa]
padding-left 2px
font-size .9em
font-weight 400
font-style normal
> .schema
opacity 0.5
> .hostname
font-weight bold
> .pathname
opacity 0.8
> .query
opacity 0.5
> .hash
font-style italic
</style>
......@@ -15,7 +15,7 @@
</div>
</header>
<div class="text">
<mk-post-html :ast="post.ast"/>
<mk-post-html :html="post.textHtml"/>
</div>
</div>
</div>
......
......@@ -16,7 +16,7 @@
</div>
</header>
<div class="body">
<mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i" :class="$style.text"/>
<mk-post-html v-if="post.textHtml" :html="post.textHtml" :i="os.i" :class="$style.text"/>
<div class="media" v-if="post.media > 0">
<mk-media-list :media-list="post.media"/>
</div>
......
......@@ -38,7 +38,7 @@
</router-link>
</header>
<div class="body">
<mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/>
<mk-post-html :class="$style.text" v-if="p.text" ref="text" :text="p.text" :i="os.i"/>
<div class="media" v-if="p.media.length > 0">
<mk-media-list :media-list="p.media"/>
</div>
......@@ -109,6 +109,7 @@ export default Vue.extend({
context: [],
contextFetching: false,
replies: [],
urls: []
};
},
computed: {
......@@ -130,15 +131,6 @@ export default Vue.extend({
},
title(): string {
return dateStringify(this.p.createdAt);
},
urls(): string[] {
if (this.p.ast) {
return this.p.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
mounted() {
......@@ -170,6 +162,21 @@ export default Vue.extend({
}
}
},
watch: {
post: {
handler(newPost, oldPost) {
if (!oldPost || newPost.text !== oldPost.text) {
this.$nextTick(() => {
const elements = this.$refs.text.$el.getElementsByTagName('a');
this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
.map(({ href }) => href);
});
}
},
immediate: true
}
},
methods: {
fetchContext() {
this.contextFetching = true;
......
......@@ -38,7 +38,7 @@
</p>
<div class="text">
<a class="reply" v-if="p.reply">%fa:reply%</a>
<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
<mk-post-html v-if="p.textHtml" ref="text" :html="p.textHtml" :i="os.i" :class="$style.text"/>
<a class="rp" v-if="p.repost">RP:</a>
</div>
<div class="media" v-if="p.media.length > 0">
......@@ -112,7 +112,8 @@ export default Vue.extend({
return {
isDetailOpened: false,
connection: null,
connectionId: null
connectionId: null,
urls: []
};
},
computed: {
......@@ -140,15 +141,6 @@ export default Vue.extend({
},
url(): string {
return `/@${this.acct}/${this.p.id}`;
},
urls(): string[] {
if (this.p.ast) {
return this.p.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
created() {
......@@ -190,6 +182,21 @@ export default Vue.extend({
(this as any).os.stream.dispose(this.connectionId);
}
},
watch: {
post: {
handler(newPost, oldPost) {
if (!oldPost || newPost.textHtml !== oldPost.textHtml) {
this.$nextTick(() => {
const elements = this.$refs.text.$el.getElementsByTagName('a');
this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
.map(({ href }) => href);
});
}
},
immediate: true
}
},
methods: {
capture(withHandler = false) {
if ((this as any).os.isSignedIn) {
......@@ -450,7 +457,7 @@ export default Vue.extend({
font-size 1.1em
color #717171
>>> .quote
>>> blockquote
margin 8px
padding 6px 12px
color #aaa
......
......@@ -2,7 +2,7 @@
<div class="mk-sub-post-content">
<div class="body">
<a class="reply" v-if="post.replyId">%fa:reply%</a>
<mk-post-html :ast="post.ast" :i="os.i"/>
<mk-post-html ref="text" :html="post.textHtml" :i="os.i"/>
<a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a>
</div>
<details v-if="post.media.length > 0">
......
......@@ -38,7 +38,7 @@
</div>
</header>
<div class="body">
<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
<mk-post-html v-if="p.text" :ast="p.text" :i="os.i" :class="$style.text"/>
<div class="tags" v-if="p.tags && p.tags.length > 0">
<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
</div>
......@@ -103,6 +103,7 @@ export default Vue.extend({
context: [],
contextFetching: false,
replies: [],
urls: []
};
},
computed: {
......@@ -127,15 +128,6 @@ export default Vue.extend({
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
: 0;
},
urls(): string[] {
if (this.p.ast) {
return this.p.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
mounted() {
......@@ -167,6 +159,21 @@ export default Vue.extend({
}
}
},
watch: {
post: {
handler(newPost, oldPost) {
if (!oldPost || newPost.text !== oldPost.text) {
this.$nextTick(() => {
const elements = this.$refs.text.$el.getElementsByTagName('a');
this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
.map(({ href }) => href);
});
}
},
immediate: true
}
},
methods: {
fetchContext() {
this.contextFetching = true;
......
......@@ -37,7 +37,7 @@
<a class="reply" v-if="p.reply">
%fa:reply%
</a>
<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
<mk-post-html v-if="p.text" ref="text" :text="p.text" :i="os.i" :class="$style.text"/>
<a class="rp" v-if="p.repost != null">RP:</a>
</div>
<div class="media" v-if="p.media.length > 0">
......@@ -90,7 +90,8 @@ export default Vue.extend({
data() {
return {
connection: null,
connectionId: null
connectionId: null,
urls: []
};
},
computed: {
......@@ -118,15 +119,6 @@ export default Vue.extend({
},
url(): string {
return `/@${this.pAcct}/${this.p.id}`;
},
urls(): string[] {
if (this.p.ast) {
return this.p.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
created() {
......@@ -168,6 +160,21 @@ export default Vue.extend({
(this as any).os.stream.dispose(this.connectionId);
}
},
watch: {
post: {
handler(newPost, oldPost) {
if (!oldPost || newPost.text !== oldPost.text) {
this.$nextTick(() => {
const elements = this.$refs.text.$el.getElementsByTagName('a');
this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
.map(({ href }) => href);
});
}
},
immediate: true
}
},
methods: {
capture(withHandler = false) {
if ((this as any).os.isSignedIn) {
......@@ -389,7 +396,7 @@ export default Vue.extend({
font-size 1.1em
color #717171
>>> .quote
>>> blockquote
margin 8px
padding 6px 12px
color #aaa
......
......@@ -2,7 +2,7 @@
<div class="mk-sub-post-content">
<div class="body">
<a class="reply" v-if="post.replyId">%fa:reply%</a>
<mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/>
<mk-post-html v-if="post.text" :ast="post.text" :i="os.i"/>
<a class="rp" v-if="post.repostId">RP: ...</a>
</div>
<details v-if="post.media.length > 0">
......
......@@ -27,8 +27,14 @@ props:
type: "string"
optional: true
desc:
ja: "投稿の本文"
en: "The text of this post"
ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)"
en: "The text of this post (in Markdown like format if local)"
- name: "textHtml"
type: "string"
optional: true
desc:
ja: "投稿の本文 (HTML) (投稿時は無視)"
en: "The text of this post (in HTML. Ignored when posting.)"
- name: "mediaIds"
type: "id(DriveFile)[]"
optional: true
......
import { lib as emojilib } from 'emojilib';
import { JSDOM } from 'jsdom';
const handlers = {
bold({ document }, { bold }) {
const b = document.createElement('b');
b.textContent = bold;
document.body.appendChild(b);
},
code({ document }, { code }) {
const pre = document.createElement('pre');
const inner = document.createElement('code');
inner.innerHTML = code;
pre.appendChild(inner);
document.body.appendChild(pre);
},
emoji({ document }, { content, emoji }) {
const found = emojilib[emoji];
const node = document.createTextNode(found ? found.char : content);
document.body.appendChild(node);
},
hashtag({ document }, { hashtag }) {
const a = document.createElement('a');
a.href = '/search?q=#' + hashtag;
a.textContent = hashtag;
},
'inline-code'({ document }, { code }) {
const element = document.createElement('code');
element.textContent = code;
document.body.appendChild(element);
},
link({ document }, { url, title }) {
const a = document.createElement('a');
a.href = url;
a.textContent = title;
document.body.appendChild(a);
},
mention({ document }, { content }) {
const a = document.createElement('a');
a.href = '/' + content;
a.textContent = content;
document.body.appendChild(a);
},
quote({ document }, { quote }) {
const blockquote = document.createElement('blockquote');
blockquote.textContent = quote;
document.body.appendChild(blockquote);
},
text({ document }, { content }) {
for (const text of content.split('\n')) {
const node = document.createTextNode(text);
document.body.appendChild(node);
const br = document.createElement('br');
document.body.appendChild(br);
}
},
url({ document }, { url }) {
const a = document.createElement('a');
a.href = url;
a.textContent = url;
document.body.appendChild(a);
}
};
export default tokens => {
const { window } = new JSDOM('');
for (const token of tokens) {
handlers[token.type](window, token);
}
return `<p>${window.document.body.innerHTML}</p>`;
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment