custom element と shadow DOM で作る読み上げ付きtextarea

Shadow DOM 使用例

Shadow DOM 使用例

custom element と shadow DOM で作る読み上げ付きtextarea

読み上げ付きtextareaを作成する。
custom element と shadow DOM で機能を持ったエレメントを作成する。

特長:

・textareaのサイズ変更にヘッダ部分が追従。
→ MutationObserver()でstyleの変更を監視。

・Closed な Shadow DOM なのにstyle設定可能。
→ Light DOM の style を Shadow DOM にコピー。

・複数の Light DOM に個別に適用。
→ Shadow DOM でクラスのインスタンス生成。

使用結果

ウェブコンポーネントにおける重要な側面の一つが、カプセル化です。マークアップ構造、スタイル、動作を隠蔽し、コード上の他のコードから分離することで、他の部分でクラッシュすることを防ぎ、コードをきれいにしておくことができます。シャドウ DOM API はこの主要部分であり、隠蔽され分離された DOM を要素に取り付けるための方法を提供しています。

オンライン音声を選択した場合は最後まで読み上げされません。及び完全なステータスが取得できません。

ここで書いたプログラムコードは実験的なものであり、実用性を求める場合は以下のページをご利用ください。

テキスト読み上げ(長文対応版)

使用方法
[HTML]
<style>
textarea-speak.one {
	width: 100%;
	height: 100px;
	border: solid 1px #4ad;
}
textarea-speak.two {
	width: 400px;
	height: 300px;
	border-style: dashed;
	border-width: 0px 2px 2px 2px;
	border-color: #d4a;
	background-color: #fee;
	padding: 5px 10px;
	color: #333;
}
</style>

<textarea-speak class="one"></textarea-speak>

<textarea-speak class="two">ウェブコンポ・・・ます。</textarea-speak>

<script defer src="./js/ce_textarea_speak.js"></script>
		
現時点のコードは以下の通り:
/**
 * 読み上げ付きtextarea
 *
 * @author ao-system, Inc.
 * @date 2024-03-02
 *
 * e.g.
 * <textarea-speak></textarea-speak>
 * <script defer src="ce_textarea_speak.js"></script>
 *
 */

(() => {
	'use strict';
	class TextareaSpeak {
		#constProperties = [
			{	prop: 'style',
				value: `
					:host > div { /*wrapper*/
						--color-bar-text1: #fff;
						--color-bar-text2: #000;
						width: 100%;
						position: relative;
						> div.first {	/*bar first*/
							width: 100%;
							padding: 2px 5px;
							box-sizing: border-box;
							> select {
								font-size: 13px;
								color: #000;
								background-color: transparent;
								border: solid 1px var(--color-bar-text2);
							}
						}
						> div.second {	/*bar second*/
							width: 100%;
							padding: 2px 5px;
							box-sizing: border-box;
							display: flex;
							align-items: center;
							column-gap: 10px;
							> div {
								user-select: none;
								font-size: 14px;
								color: var(--color-bar-text1);
								cursor: pointer;
								opacity: 1;
								&.off {
									opacity: 0.5;
								}
							}
							> span {
								user-select: none;
								font-size: 14px;
								color: var(--color-bar-text2);
							}
						}
						> textarea {
							box-sizing: border-box;
						}
					}
				`,
			},
			{	prop: 'styleElm',
				value: document.createElement('style'),
			},
			{	prop: 'wrapElm',
				value: document.createElement('div'),
			},
			{	prop: 'barFirstElm',
				value: document.createElement('div'),
			},
			{	prop: 'barSecondElm',
				value: document.createElement('div'),
			},
			{	prop: 'selectElm',
				value: document.createElement('select'),
			},
			{	prop: 'playElm',
				value: document.createElement('div'),
			},
			{	prop: 'pauseElm',
				value: document.createElement('div'),
			},
			{	prop: 'resumeElm',
				value: document.createElement('div'),
			},
			{	prop: 'cancelElm',
				value: document.createElement('div'),
			},
			{	prop: 'statusElm',
				value: document.createElement('span'),
			},
			{	prop: 'textareaElm',
				value: document.createElement('textarea'),
			},
		];
		constructor() {
			//class内でwritable:falseの定数を作成する方法
			this.#constProperties.forEach((obj) => Object.defineProperty(this, obj.prop, {value:obj.value, writable:false}));
			this.#setVoiceSelect();
			speechSynthesis.addEventListener('voiceschanged',() => {
				this.#setVoiceSelect();
				this.#cancel();
			});
		}
		#setVoiceSelect() {
			if (this.selectElm.innerHTML != '') {
				return;
			}
			const voices = speechSynthesis.getVoices();
			const optionVoices = [];
			for (let i = 0; i < voices.length; i++) {
				const lang = voices[i]['lang'];
				const nm = voices[i]['name'];
				const df = voices[i]['default'];
				const lo = voices[i]['localService'];
				const elm = document.createElement('option');
				elm.value = i;
				if (df) {
					elm.setAttribute('selected','selected');
				}
				elm.innerHTML = lang + ':' + nm;
				elm.setAttribute('data-local',lo);
				optionVoices.push(elm);
			}
			for (let i = 0; i < optionVoices.length; i++) {
				this.selectElm.appendChild(optionVoices[i]);
			}
		}
		#speak() {
			if (speechSynthesis.speaking) {
				return;
			}
			const ssu = new SpeechSynthesisUtterance();
			const voices = speechSynthesis.getVoices();
			const num = this.selectElm.value;
			ssu.voice = voices[num];
			ssu.volume = 1;
			ssu.rate = 1;
			ssu.pitch = 1;
			ssu.text = this.textareaElm.value;
			speechSynthesis.speak(ssu);
			ssu.addEventListener('start',  () => { this.#setSpeakStatus(); });
			ssu.addEventListener('end',    () => { this.#setSpeakStatus(); });
			ssu.addEventListener('pause',  () => { this.#setSpeakStatus(); });
			ssu.addEventListener('resume', () => { this.#setSpeakStatus(); });
			ssu.addEventListener('error',  () => { this.#setSpeakStatus(); });
		}
		#setSpeakStatus() {
			const selectedOption = this.selectElm.options[this.selectElm.selectedIndex];
			const dataLocal = selectedOption.getAttribute('data-local');
			if (dataLocal == 'true') {
				if (speechSynthesis.speaking == true && speechSynthesis.paused == false && speechSynthesis.pending == false) {
					this.statusElm.textContent = 'start';
					this.playElm.classList.add('off');
					this.pauseElm.classList.remove('off');
					this.resumeElm.classList.add('off');
					this.cancelElm.classList.remove('off');
				} else if (speechSynthesis.speaking == true && speechSynthesis.paused == true && speechSynthesis.pending == false) {
					this.statusElm.textContent = 'pause';
					this.playElm.classList.add('off');
					this.pauseElm.classList.add('off');
					this.resumeElm.classList.remove('off');
					this.cancelElm.classList.remove('off');
				} else {
					this.statusElm.textContent = 'ready';
					this.playElm.classList.remove('off');
					this.pauseElm.classList.add('off');
					this.resumeElm.classList.add('off');
					this.cancelElm.classList.add('off');
				}
			} else {
				if (speechSynthesis.speaking == true && speechSynthesis.paused == false && speechSynthesis.pending == false) {
					this.statusElm.textContent = 'start';
					this.playElm.classList.add('off');
					this.pauseElm.classList.remove('off');
					this.resumeElm.classList.remove('off');
					this.cancelElm.classList.remove('off');
				} else {
					this.statusElm.textContent = 'ready';
					this.playElm.classList.remove('off');
					this.pauseElm.classList.add('off');
					this.resumeElm.classList.add('off');
					this.cancelElm.classList.add('off');
				}
			}
		}
		#pause() {
			speechSynthesis.pause();
		}
		#resume() {
			speechSynthesis.resume();
		}
		#cancel() {
			speechSynthesis.cancel();
		}
		//styleを作成
		#styleElement() {
			this.styleElm.textContent = this.style;
		}
		//barを作成
		#barElement(elementRef) {
			this.barFirstElm.classList.add('first');
			this.barSecondElm.classList.add('second');
			//
			this.barFirstElm.appendChild(this.selectElm);
			//
			this.playElm.textContent = 'play';
			this.playElm.addEventListener('click',() => {this.#speak();});
			this.barSecondElm.appendChild(this.playElm);
			//
			this.pauseElm.textContent = 'pause';
			this.pauseElm.addEventListener('click',() => {this.#pause();});
			this.pauseElm.classList.add('off');
			this.barSecondElm.appendChild(this.pauseElm);
			//
			this.resumeElm.textContent = 'resume';
			this.resumeElm.addEventListener('click',() => {this.#resume();});
			this.resumeElm.classList.add('off');
			this.barSecondElm.appendChild(this.resumeElm);
			//
			this.cancelElm.textContent = 'cancel';
			this.cancelElm.addEventListener('click',() => {this.#cancel();});
			this.cancelElm.classList.add('off');
			this.barSecondElm.appendChild(this.cancelElm);
			//
			this.barSecondElm.appendChild(this.statusElm);
			//
			this.wrapElm.appendChild(this.barFirstElm);
			this.wrapElm.appendChild(this.barSecondElm);
		}
		//textareaを作成
		#textareaElement(elementRef) {
			this.textareaElm.value = elementRef.innerHTML;
			this.wrapElm.appendChild(this.textareaElm);
		}
		//shadowDOMにappend
		#attachShadow(elementRef,styleElm,wrapElm) {
			const shadowClosed = elementRef.attachShadow({mode:'closed'});
			shadowClosed.innerHTML = '';
			shadowClosed.appendChild(this.styleElm);
			shadowClosed.appendChild(this.wrapElm);
		}
		//textareaのスタイル変更を監視
		#observeTextarea() {
			const observer = new MutationObserver((mutationsList, observer) => {
				for(const mutation of mutationsList) {
					if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
						//textareaのwidthが変化したらwrapperのwidthに適用
						this.wrapElm.style.width = this.textareaElm.style.width;
					}
				}
			});
			observer.observe(this.textareaElm, { attributes:true, attributeFilter:['style'] });
		}
		//textarea-speakのスタイルを取得してshadowDOM内のエレメントに適用する
		#applyStyle(elementRef) {
			const computedStyle = window.getComputedStyle(elementRef);
			for (let i = 0; i < computedStyle.length; i++) {
			    const propertyName = computedStyle[i];
			    const propertyValue = computedStyle.getPropertyValue(propertyName);
				if (['box-sizing','resize','white-space-collapse'].includes(propertyName) == false) {	//これらを除く
				    this.textareaElm.style.setProperty(propertyName, propertyValue);
				}
			}
			//textarea-speakのborderTopColorをshadow内barのbackgroundColorに適用
			this.barFirstElm.style.backgroundColor = computedStyle.borderTopColor;
			this.barSecondElm.style.backgroundColor = computedStyle.borderTopColor;
			//textarea-speakのborderTopLeftRadius,borderTopRightRadiusをbarFirstElmのborderTopLeftRadius,borderTopRightRadiusに適用
			this.barFirstElm.style.setProperty('border-top-left-radius',computedStyle.borderTopLeftRadius);
			this.barFirstElm.style.setProperty('border-top-right-radius',computedStyle.borderTopRightRadius);
			this.textareaElm.style.borderTopLeftRadius = 0;
			this.textareaElm.style.borderTopRightRadius = 0;
			//textareaのwidthをwrapperのwidthに適用
			this.wrapElm.style.width = this.textareaElm.clientWidth + 'px';
			//textarea-speakのスタイルを削除。<textarea-sort/>を非表示にする為
			elementRef.style.border = 'none';
			elementRef.style.padding = 0;
		}
		render(elementRef) {
			this.#styleElement();
			this.#barElement(elementRef);
			this.#textareaElement(elementRef);
			this.#attachShadow(elementRef);
			this.#observeTextarea();
			this.#applyStyle(elementRef);
		}
	}
	customElements.define('textarea-speak',
		class extends HTMLElement {
			constructor() {
				super();
				(new TextareaSpeak()).render(this);
			}
		}
	);
})();
資料
custom element:
https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_custom_elements
shadow DOM:
https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_shadow_DOM
この記事は2024年3月当時の物です。
このサイトについてのお問い合わせはエーオーシステムまでお願いいたします。
ご使用上の過失の有無を問わず、本プログラムの運用において発生した損害に対するいかなる請求があったとしても、その責任を負うものではありません。