Merge branch 'develop' into refactor-ui
This commit is contained in:
		
						commit
						2e898c173c
					
				
					 7 changed files with 204 additions and 35 deletions
				
			
		|  | @ -10,11 +10,15 @@ | |||
| ## 12.x.x (unreleased) | ||||
| 
 | ||||
| ### Improvements | ||||
| - アニメーションを減らす設定をメニューのアニメーションにも適用するように | ||||
| - クライアント: アニメーションを減らす設定をメニューのアニメーションにも適用するように | ||||
| - クライアント: MFM関数構文のサジェストを実装 | ||||
| - ActivityPub: HTML -> MFMの変換を強化 | ||||
| 
 | ||||
| ### Bugfixes | ||||
| - Fix createDeleteAccountJob | ||||
| - admin inbox queue does not show individual jobs | ||||
| - クライアント: ヘッダーのタブが折り返される問題を修正 | ||||
| - クライアント: ヘッダーにタブが表示されている状態でタイトルをクリックしたときにタブ選択が表示されるのを修正 | ||||
| 
 | ||||
| ## 12.91.0 (2021/09/22) | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,12 +10,12 @@ | |||
| 		</li> | ||||
| 		<li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li> | ||||
| 	</ol> | ||||
| 	<ol class="hashtags" ref="suggests" v-if="hashtags.length > 0"> | ||||
| 	<ol class="hashtags" ref="suggests" v-else-if="hashtags.length > 0"> | ||||
| 		<li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1"> | ||||
| 			<span class="name">{{ hashtag }}</span> | ||||
| 		</li> | ||||
| 	</ol> | ||||
| 	<ol class="emojis" ref="suggests" v-if="emojis.length > 0"> | ||||
| 	<ol class="emojis" ref="suggests" v-else-if="emojis.length > 0"> | ||||
| 		<li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> | ||||
| 			<span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> | ||||
| 			<span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span> | ||||
|  | @ -24,6 +24,11 @@ | |||
| 			<span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span> | ||||
| 		</li> | ||||
| 	</ol> | ||||
| 	<ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0"> | ||||
| 		<li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1"> | ||||
| 			<span class="tag">{{ tag }}</span> | ||||
| 		</li> | ||||
| 	</ol> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -106,6 +111,8 @@ emojiDefinitions.sort((a, b) => a.name.length - b.name.length); | |||
| const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); | ||||
| //#endregion | ||||
| 
 | ||||
| const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle']; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	props: { | ||||
| 		type: { | ||||
|  | @ -137,11 +144,6 @@ export default defineComponent({ | |||
| 			type: Number, | ||||
| 			required: true, | ||||
| 		}, | ||||
| 
 | ||||
| 		showing: { | ||||
| 			type: Boolean, | ||||
| 			required: true | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	emits: ['done', 'closed'], | ||||
|  | @ -154,18 +156,11 @@ export default defineComponent({ | |||
| 			hashtags: [], | ||||
| 			emojis: [], | ||||
| 			items: [], | ||||
| 			mfmTags: [], | ||||
| 			select: -1, | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	watch: { | ||||
| 		showing() { | ||||
| 			if (!this.showing) { | ||||
| 				this.$emit('closed'); | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	updated() { | ||||
| 		this.setPosition(); | ||||
| 		this.items = (this.$refs.suggests as Element | undefined)?.children || []; | ||||
|  | @ -236,7 +231,7 @@ export default defineComponent({ | |||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			if (this.type == 'user') { | ||||
| 			if (this.type === 'user') { | ||||
| 				if (this.q == null) { | ||||
| 					this.users = []; | ||||
| 					this.fetching = false; | ||||
|  | @ -262,7 +257,7 @@ export default defineComponent({ | |||
| 						sessionStorage.setItem(cacheKey, JSON.stringify(users)); | ||||
| 					}); | ||||
| 				} | ||||
| 			} else if (this.type == 'hashtag') { | ||||
| 			} else if (this.type === 'hashtag') { | ||||
| 				if (this.q == null || this.q == '') { | ||||
| 					this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); | ||||
| 					this.fetching = false; | ||||
|  | @ -286,7 +281,7 @@ export default defineComponent({ | |||
| 						}); | ||||
| 					} | ||||
| 				} | ||||
| 			} else if (this.type == 'emoji') { | ||||
| 			} else if (this.type === 'emoji') { | ||||
| 				if (this.q == null || this.q == '') { | ||||
| 					// 最近使った絵文字をサジェスト | ||||
| 					this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null); | ||||
|  | @ -314,6 +309,13 @@ export default defineComponent({ | |||
| 				} | ||||
| 
 | ||||
| 				this.emojis = matched; | ||||
| 			} else if (this.type === 'mfmTag') { | ||||
| 				if (this.q == null || this.q == '') { | ||||
| 					this.mfmTags = MFM_TAGS; | ||||
| 					return; | ||||
| 				} | ||||
| 
 | ||||
| 				this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q)); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
|  | @ -490,5 +492,11 @@ export default defineComponent({ | |||
| 			margin: 0 0 0 8px; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .mfmTags > li { | ||||
| 
 | ||||
| 		.name { | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -5,9 +5,11 @@ | |||
| 			<template #prefix><i class="fas fa-search"></i></template> | ||||
| 		</MkInput> | ||||
| 
 | ||||
| 		<!-- たくさんあると邪魔 | ||||
| 		<div class="tags"> | ||||
| 			<span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> | ||||
| 		</div> | ||||
| 		--> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<MkFolder class="emojis" v-if="searchEmojis"> | ||||
|  |  | |||
|  | @ -7,9 +7,9 @@ export class Autocomplete { | |||
| 	private suggestion: { | ||||
| 		x: Ref<number>; | ||||
| 		y: Ref<number>; | ||||
| 		q: Ref<string>; | ||||
| 		q: Ref<string | null>; | ||||
| 		close: Function; | ||||
| 	}; | ||||
| 	} | null; | ||||
| 	private textarea: any; | ||||
| 	private vm: any; | ||||
| 	private currentType: string; | ||||
|  | @ -70,11 +70,13 @@ export class Autocomplete { | |||
| 		const mentionIndex = text.lastIndexOf('@'); | ||||
| 		const hashtagIndex = text.lastIndexOf('#'); | ||||
| 		const emojiIndex = text.lastIndexOf(':'); | ||||
| 		const mfmTagIndex = text.lastIndexOf('$'); | ||||
| 
 | ||||
| 		const max = Math.max( | ||||
| 			mentionIndex, | ||||
| 			hashtagIndex, | ||||
| 			emojiIndex); | ||||
| 			emojiIndex, | ||||
| 			mfmTagIndex); | ||||
| 
 | ||||
| 		if (max == -1) { | ||||
| 			this.close(); | ||||
|  | @ -83,6 +85,7 @@ export class Autocomplete { | |||
| 
 | ||||
| 		const isMention = mentionIndex != -1; | ||||
| 		const isHashtag = hashtagIndex != -1; | ||||
| 		const isMfmTag = mfmTagIndex != -1; | ||||
| 		const isEmoji = emojiIndex != -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); | ||||
| 
 | ||||
| 		let opened = false; | ||||
|  | @ -114,6 +117,14 @@ export class Autocomplete { | |||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (isMfmTag && !opened) { | ||||
| 			const mfmTag = text.substr(mfmTagIndex + 1); | ||||
| 			if (!mfmTag.includes(' ')) { | ||||
| 				this.open('mfmTag', mfmTag.replace('[', '')); | ||||
| 				opened = true; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if (!opened) { | ||||
| 			this.close(); | ||||
| 		} | ||||
|  | @ -122,7 +133,7 @@ export class Autocomplete { | |||
| 	/** | ||||
| 	 * サジェストを提示します。 | ||||
| 	 */ | ||||
| 	private async open(type: string, q: string) { | ||||
| 	private async open(type: string, q: string | null) { | ||||
| 		if (type != this.currentType) { | ||||
| 			this.close(); | ||||
| 		} | ||||
|  | @ -244,6 +255,22 @@ export class Autocomplete { | |||
| 				const pos = trimmedBefore.length + value.length; | ||||
| 				this.textarea.setSelectionRange(pos, pos); | ||||
| 			}); | ||||
| 		} else if (type == 'mfmTag') { | ||||
| 			const source = this.text; | ||||
| 
 | ||||
| 			const before = source.substr(0, caret); | ||||
| 			const trimmedBefore = before.substring(0, before.lastIndexOf('$')); | ||||
| 			const after = source.substr(caret); | ||||
| 
 | ||||
| 			// 挿入
 | ||||
| 			this.text = `${trimmedBefore}$[${value} ]${after}`; | ||||
| 
 | ||||
| 			// キャレットを戻す
 | ||||
| 			this.vm.$nextTick(() => { | ||||
| 				this.textarea.focus(); | ||||
| 				const pos = trimmedBefore.length + (value.length + 3); | ||||
| 				this.textarea.setSelectionRange(pos, pos); | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -141,6 +141,7 @@ export default defineComponent({ | |||
| 
 | ||||
| 		showTabsPopup(ev) { | ||||
| 			if (!this.hasTabs) return; | ||||
| 			if (!this.narrow) return; | ||||
| 			ev.preventDefault(); | ||||
| 			ev.stopPropagation(); | ||||
| 			const menu = this.info.tabs.map(tab => ({ | ||||
|  | @ -218,6 +219,7 @@ export default defineComponent({ | |||
| 		white-space: nowrap; | ||||
| 		text-align: left; | ||||
| 		font-weight: bold; | ||||
| 		flex-shrink: 0; | ||||
| 
 | ||||
| 		> .avatar { | ||||
| 			$size: 32px; | ||||
|  | @ -263,6 +265,8 @@ export default defineComponent({ | |||
| 	> .tabs { | ||||
| 		margin-left: 16px; | ||||
| 		font-size: 0.8em; | ||||
| 		overflow: auto; | ||||
| 		white-space: nowrap; | ||||
| 
 | ||||
| 		> .tab { | ||||
| 			display: inline-block; | ||||
|  |  | |||
|  | @ -5,7 +5,9 @@ import { URL } from 'url'; | |||
| const urlRegex     = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; | ||||
| const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; | ||||
| 
 | ||||
| export function fromHtml(html: string, hashtagNames?: string[]): string { | ||||
| export function fromHtml(html: string, hashtagNames?: string[]): string | null { | ||||
| 	if (html == null) return null; | ||||
| 
 | ||||
| 	const dom = parse5.parseFragment(html); | ||||
| 
 | ||||
| 	let text = ''; | ||||
|  | @ -19,6 +21,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { | |||
| 	function getText(node: parse5.Node): string { | ||||
| 		if (treeAdapter.isTextNode(node)) return node.value; | ||||
| 		if (!treeAdapter.isElementNode(node)) return ''; | ||||
| 		if (node.nodeName === 'br') return '\n'; | ||||
| 
 | ||||
| 		if (node.childNodes) { | ||||
| 			return node.childNodes.map(n => getText(n)).join(''); | ||||
|  | @ -27,6 +30,14 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { | |||
| 		return ''; | ||||
| 	} | ||||
| 
 | ||||
| 	function appendChildren(childNodes: parse5.ChildNode[]): void { | ||||
| 		if (childNodes) { | ||||
| 			for (const n of childNodes) { | ||||
| 				analyze(n); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	function analyze(node: parse5.Node) { | ||||
| 		if (treeAdapter.isTextNode(node)) { | ||||
| 			text += node.value; | ||||
|  | @ -42,6 +53,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { | |||
| 				break; | ||||
| 
 | ||||
| 			case 'a': | ||||
| 			{ | ||||
| 				const txt = getText(node); | ||||
| 				const rel = node.attrs.find(x => x.name === 'rel'); | ||||
| 				const href = node.attrs.find(x => x.name === 'href'); | ||||
|  | @ -87,23 +99,111 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { | |||
| 					text += generateLink(); | ||||
| 				} | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 'h1': | ||||
| 			{ | ||||
| 				text += '【'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				text += '】\n'; | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 'b': | ||||
| 			case 'strong': | ||||
| 			{ | ||||
| 				text += '**'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				text += '**'; | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 'small': | ||||
| 			{ | ||||
| 				text += '<small>'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				text += '</small>'; | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 's': | ||||
| 			case 'del': | ||||
| 			{ | ||||
| 				text += '~~'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				text += '~~'; | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 'i': | ||||
| 			case 'em': | ||||
| 			{ | ||||
| 				text += '<i>'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				text += '</i>'; | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			// block code (<pre><code>)
 | ||||
| 			case 'pre': { | ||||
| 				if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') { | ||||
| 					text += '```\n'; | ||||
| 					text += getText(node.childNodes[0]); | ||||
| 					text += '\n```\n'; | ||||
| 				} else { | ||||
| 					appendChildren(node.childNodes); | ||||
| 				} | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			// inline code (<code>)
 | ||||
| 			case 'code': { | ||||
| 				text += '`'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				text += '`'; | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 'blockquote': { | ||||
| 				const t = getText(node); | ||||
| 				if (t) { | ||||
| 					text += '> '; | ||||
| 					text += t.split('\n').join(`\n> `); | ||||
| 				} | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			case 'p': | ||||
| 			case 'h2': | ||||
| 			case 'h3': | ||||
| 			case 'h4': | ||||
| 			case 'h5': | ||||
| 			case 'h6': | ||||
| 			{ | ||||
| 				text += '\n\n'; | ||||
| 				if (node.childNodes) { | ||||
| 					for (const n of node.childNodes) { | ||||
| 						analyze(n); | ||||
| 					} | ||||
| 				} | ||||
| 				appendChildren(node.childNodes); | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			default: | ||||
| 				if (node.childNodes) { | ||||
| 					for (const n of node.childNodes) { | ||||
| 						analyze(n); | ||||
| 					} | ||||
| 				} | ||||
| 			// other block elements
 | ||||
| 			case 'div': | ||||
| 			case 'header': | ||||
| 			case 'footer': | ||||
| 			case 'article': | ||||
| 			case 'li': | ||||
| 			case 'dt': | ||||
| 			case 'dd': | ||||
| 			{ | ||||
| 				text += '\n'; | ||||
| 				appendChildren(node.childNodes); | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			default:	// includes inline elements
 | ||||
| 			{ | ||||
| 				appendChildren(node.childNodes); | ||||
| 				break; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  |  | |||
							
								
								
									
										24
									
								
								test/mfm.ts
									
										
									
									
									
								
							
							
						
						
									
										24
									
								
								test/mfm.ts
									
										
									
									
									
								
							|  | @ -19,6 +19,30 @@ describe('toHtml', () => { | |||
| }); | ||||
| 
 | ||||
| describe('fromHtml', () => { | ||||
| 	it('p', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<p>a</p><p>b</p>'), 'a\n\nb'); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('block element', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<div>a</div><div>b</div>'), 'a\nb'); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('inline element', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<ul><li>a</li><li>b</li></ul>'), 'a\nb'); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('block code', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<pre><code>a\nb</code></pre>'), '```\na\nb\n```'); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('inline code', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<code>a</code>'), '`a`'); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('quote', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<blockquote>a\nb</blockquote>'), '> a\n> b'); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('br', () => { | ||||
| 		assert.deepStrictEqual(fromHtml('<p>abc<br><br/>d</p>'), 'abc\n\nd'); | ||||
| 	}); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue