FileMaker Web ビューアのテキストエリアの挙動

Claris FileMaker の Web ビューアでおかしな挙動を見つけ、その対策を考えたので備忘録として残します。

おかしな挙動

macOS においてテキストエリアで左右矢印キーを入力すると四角い文字が挿入される。また、⌘z や⌘⇧z でアンドゥやリドゥが効かない。

対策

JavaScript でキーダウンを検知して、通常の動きをキャンセルしたり、入力された履歴をとり、アンドゥ、リドゥを自力で実装する。

data:text/html,
<html>


<head>
    <style>
        div {
            padding-bottom: 0.5em;
        }
    </style>
</head>



<body>

    <div>
        <textarea class="custom-textarea" rows="10" cols="30"></textarea>
        <textarea class="custom-textarea" rows="10" cols="30"></textarea>

    </div>




    <script>

        function createTextArea(textarea, submitCallback) {
            const history = [""];
            let historyIndex = 0;
            let lastInputTime = Date.now();
            let isComposing = false;

            const updateHistory = () => {
                const now = Date.now();
                if (now - lastInputTime < 100 || isComposing) {
                    lastInputTime = now;
                    return;
                }
                if (historyIndex < history.length - 1) {
                    history.splice(historyIndex + 1);
                }
                history.push(textarea.value);
                historyIndex++;
                lastInputTime = now;
            };

            const setHistory = (text) => {
                history.length = 0;
                history.push(text);
                historyIndex = 0;
            };

            textarea.addEventListener('input', (e) => {
                if (!isComposing) {
                    updateHistory(textarea);
                }
            });

            textarea.addEventListener('compositionstart', (e) => {
                isComposing = true;
            });

            textarea.addEventListener('compositionend', (e) => {
                isComposing = false;
                updateHistory(textarea);
            });

            textarea.onkeydown = (e) => {
                const isEnterKey = (e) => e.key === 'Enter' || e.key === 'Return';
                const isMetaOrCtrlKey = (e) => e.metaKey || e.ctrlKey;
                const isArrowKey = (e) => e.key === 'ArrowRight' || e.key === 'ArrowLeft';
                const isAtInputBoundary = (e) => {
                    return (e.key === 'ArrowRight' && e.target.value.length === e.target.selectionStart) ||
                        (e.key === 'ArrowLeft' && e.target.selectionStart === 0);
                };

                if (isArrowKey(e) && isAtInputBoundary(e) && !e.shiftKey) {
                    e.preventDefault();
                } else if (isEnterKey(e) && isMetaOrCtrlKey(e)) {
                    submitCallback();
                } else if (isComposing) {
                    return;
                } else if (isMetaOrCtrlKey(e) && e.key === 'z' && !e.shiftKey) {
                    e.preventDefault();
                    if (historyIndex > 0) {
                        historyIndex--;
                        e.target.value = history[historyIndex];
                        setTimeout(() => {
                            lastInputTime = Date.now();
                        }, 0);
                    }
                } else if (isMetaOrCtrlKey(e) && e.key === 'z' && e.shiftKey) {
                    e.preventDefault();
                    if (historyIndex < history.length - 1) {
                        historyIndex++;
                        e.target.value = history[historyIndex];
                        setTimeout(() => {
                            lastInputTime = Date.now();
                        }, 0);
                    }
                }
            };

            return {
                submit: () => {
                    alert("投稿されたよ");
                    setHistory("");
                },
            };
        }

        const textAreaInstances = [];

        document.querySelectorAll('.custom-textarea').forEach((textareaElement) => {
            const instance = createTextArea(textareaElement, () => {
                instance.submit();
            });
            textAreaInstances.push(instance);
        });

    </script>

</html>

ややアンドゥ、リドゥの箇所は調整必要かもしれないし、FileMaker の Web ビューア側で解決すれば、このような実装はしなくて済む。

サンプル

スペシャルサンクス

野田先生



さらに最適化した例。

data:text/html,

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>TextArea Undo Redo</title>
</head>

<body>
    <textarea id="textareaId1" rows="10" cols="50"></textarea>
    <button id="clearButton1">Clear Undo History for TextArea 1</button>
    <br><br>
    <textarea id="textareaId2" rows="10" cols="50"></textarea>
    <button id="clearButton2">Clear Undo History for TextArea 2</button>

    <script>
        class TextAreaUndoRedo {
            constructor(textareaId, defaultText = '') {
                this.textArea = document.getElementById(textareaId);
                this.undoStack = [defaultText !== '' ? defaultText : ''];
                this.redoStack = [];

                this.isComposing = false;
                this.initEventListeners();
            }

            initEventListeners() {
                this.textArea.addEventListener('keydown', (event) => {
                    const isCursorAtLeftEdge = this.textArea.selectionStart === 0;
                    const isCursorAtRightEdge = this.textArea.selectionStart === this.textArea.value.length;
                    const isTextSelected = this.textArea.selectionStart !== this.textArea.selectionEnd;

                    if (event.key === 'ArrowLeft' && isCursorAtLeftEdge && !event.shiftKey && !isTextSelected) {
                        event.preventDefault();
                    }
                    if (event.key === 'ArrowRight' && isCursorAtRightEdge && !event.shiftKey && !isTextSelected) {
                        event.preventDefault();
                    }

                    if ((event.metaKey || event.ctrlKey) && event.key === 'z') {
                        event.preventDefault();
                        if (!event.shiftKey && this.undoStack.length > 1) {
                            this.redoStack.push(this.undoStack.pop());
                            this.textArea.value = this.undoStack[this.undoStack.length - 1];
                        } else if (event.shiftKey && this.redoStack.length > 0) {
                            this.undoStack.push(this.redoStack.pop());
                            this.textArea.value = this.undoStack[this.undoStack.length - 1];
                        }
                    }
                });

                this.textArea.addEventListener('input', () => {
                    if (!this.isComposing) {
                        const lastValue = this.undoStack[this.undoStack.length - 1];
                        if (this.textArea.value !== lastValue) {
                            this.undoStack.push(this.textArea.value);
                            this.redoStack = [];
                        }
                    }
                });

                this.textArea.addEventListener('compositionstart', () => {
                    this.isComposing = true;
                });

                this.textArea.addEventListener('compositionend', () => {
                    this.isComposing = false;
                    const lastValue = this.undoStack[this.undoStack.length - 1];
                    if (this.textArea.value !== lastValue) {
                        this.undoStack.push(this.textArea.value);
                        this.redoStack = [];
                    }
                });
            }

            clearUndoHistory() {
                this.textArea.value = '';
                this.undoStack = [''];
                this.redoStack = [];
            }
        }

        function clearTextAreaHistory(textareaUndoRedoInstance) {
            textareaUndoRedoInstance.clearUndoHistory();
            textareaUndoRedoInstance.undoStack.push('');
        }

        // 初期化
        const textareaUndoRedo1 = new TextAreaUndoRedo('textareaId1', '');
        const textareaUndoRedo2 = new TextAreaUndoRedo('textareaId2', '');

        // クリアボタンを取得し、クリックイベントリスナーを追加
        const clearButton1 = document.getElementById('clearButton1');
        clearButton1.addEventListener('click', () => clearTextAreaHistory(textareaUndoRedo1));

        const clearButton2 = document.getElementById('clearButton2');
        clearButton2.addEventListener('click', () => clearTextAreaHistory(textareaUndoRedo2));

    </script>
</body>

</html>


この記事が気に入ったらサポートをしてみませんか?