From c999ec427418b5d6792e1ff4cd3cee88d6ecece8 Mon Sep 17 00:00:00 2001 From: Anh NG Date: Thu, 11 Sep 2025 09:52:14 +0700 Subject: [PATCH] chore: switch to anvim@0.1.2, remove local engine and legacy extension, update tests and editor autosave\n\n- Replace in-repo ANVIM with npm package anvim@0.1.2\n- Update imports in utils and tests; adapt tests to package behavior\n- Add Vite test deps inline for anvim\n- Add editor autosave (localStorage + debounce)\n- Remove deprecated extension files\n- Update yarn.lock and package.json --- extension/background/service-worker.js | 82 -- extension/content/content-script.js | 276 ------ extension/manifest.json | 46 - extension/popup/popup.css | 243 ------ extension/popup/popup.html | 128 --- extension/popup/popup.js | 126 --- package.json | 1 + src/components/editor.tsx | 98 ++- src/engine/methods/anvim.ts | 1085 ------------------------ src/utils/vietnamese-input.ts | 10 +- tests/anvim.test.ts | 21 +- vite.config.ts | 3 + yarn.lock | 5 + 13 files changed, 109 insertions(+), 2015 deletions(-) delete mode 100644 extension/background/service-worker.js delete mode 100644 extension/content/content-script.js delete mode 100644 extension/manifest.json delete mode 100644 extension/popup/popup.css delete mode 100644 extension/popup/popup.html delete mode 100644 extension/popup/popup.js delete mode 100644 src/engine/methods/anvim.ts diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js deleted file mode 100644 index f85a5dd..0000000 --- a/extension/background/service-worker.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * VinaKey Extension Background Service Worker - */ - -// Extension installation and updates -chrome.runtime.onInstalled.addListener((details) => { - console.log("VinaKey extension installed:", details.reason); - - // Set default settings - chrome.storage.sync.set({ - inputMethod: "TELEX", - enabled: true, - showTooltips: true, - }); -}); - -// Handle messages from content scripts -chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - switch (request.action) { - case "getSettings": - chrome.storage.sync.get( - ["inputMethod", "enabled", "showTooltips"], - (settings) => { - sendResponse({ - inputMethod: settings.inputMethod || "TELEX", - enabled: settings.enabled !== false, - showTooltips: settings.showTooltips !== false, - }); - }, - ); - return true; // Keep message channel open for async response - - case "updateSettings": - chrome.storage.sync.set(request.settings, () => { - sendResponse({ success: true }); - - // Notify all content scripts of settings change - chrome.tabs.query({}, (tabs) => { - tabs.forEach((tab) => { - chrome.tabs.sendMessage(tab.id, { - action: "settingsUpdated", - settings: request.settings, - }); - }); - }); - }); - return true; - - case "toggleEnabled": - chrome.storage.sync.get(["enabled"], (result) => { - const newState = !result.enabled; - chrome.storage.sync.set({ enabled: newState }, () => { - sendResponse({ enabled: newState }); - - // Update extension icon based on state - updateExtensionIcon(newState); - - // Notify all content scripts - chrome.tabs.query({}, (tabs) => { - tabs.forEach((tab) => { - chrome.tabs.sendMessage(tab.id, { - action: "toggleEnabled", - enabled: newState, - }); - }); - }); - }); - }); - return true; - } -}); - -// Update extension icon based on enabled state -function updateExtensionIcon(enabled) { - const iconPath = enabled ? "icons/icon-32.png" : "icons/icon-32-disabled.png"; - chrome.action.setIcon({ path: iconPath }); -} - -// Initialize icon on startup -chrome.storage.sync.get(["enabled"], (result) => { - updateExtensionIcon(result.enabled !== false); -}); diff --git a/extension/content/content-script.js b/extension/content/content-script.js deleted file mode 100644 index afd84f9..0000000 --- a/extension/content/content-script.js +++ /dev/null @@ -1,276 +0,0 @@ -/** - * VinaKey Content Script - Injects Vietnamese input into web pages - */ - -// Import the Vietnamese input engine (will be bundled) -import { TelexInputMethod } from "../../src/engine/methods/telex.js"; - -class VinaKeyContentScript { - constructor() { - this.inputMethod = new TelexInputMethod(); - this.enabled = true; - this.currentMethod = "TELEX"; - this.attachedElements = new Set(); - this.compositionData = new Map(); // Track composition state per element - - this.init(); - } - - async init() { - // Get initial settings - const settings = await this.getSettings(); - this.enabled = settings.enabled; - this.currentMethod = settings.inputMethod; - - // Start monitoring for input elements - this.attachToExistingElements(); - this.observeNewElements(); - - // Listen for settings updates - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - this.handleMessage(message, sender, sendResponse); - }); - - console.log("VinaKey content script initialized"); - } - - async getSettings() { - return new Promise((resolve) => { - chrome.runtime.sendMessage({ action: "getSettings" }, resolve); - }); - } - - handleMessage(message, sender, sendResponse) { - switch (message.action) { - case "settingsUpdated": - this.enabled = message.settings.enabled; - this.currentMethod = message.settings.inputMethod; - this.updateAttachedElements(); - break; - - case "toggleEnabled": - this.enabled = message.enabled; - this.updateAttachedElements(); - break; - } - } - - attachToExistingElements() { - // Find all text input elements - const inputs = document.querySelectorAll( - 'input[type="text"], input[type="search"], textarea, [contenteditable="true"]', - ); - - inputs.forEach((element) => this.attachToElement(element)); - } - - observeNewElements() { - // Watch for new input elements being added to the DOM - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - // Check if the added node is an input element - if (this.isInputElement(node)) { - this.attachToElement(node); - } - - // Check for input elements within the added node - const inputs = node.querySelectorAll?.( - 'input[type="text"], input[type="search"], textarea, [contenteditable="true"]', - ); - inputs?.forEach((element) => this.attachToElement(element)); - } - }); - }); - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - } - - isInputElement(element) { - const tagName = element.tagName?.toLowerCase(); - return ( - (tagName === "input" && ["text", "search"].includes(element.type)) || - tagName === "textarea" || - element.contentEditable === "true" - ); - } - - attachToElement(element) { - if (this.attachedElements.has(element)) return; - - this.attachedElements.add(element); - this.compositionData.set(element, { - buffer: "", - lastProcessedLength: 0, - }); - - // Add event listeners - element.addEventListener("keydown", (e) => this.handleKeyDown(e, element)); - element.addEventListener("input", (e) => this.handleInput(e, element)); - element.addEventListener("blur", (e) => this.handleBlur(e, element)); - - // Add visual indicator when focused (optional) - element.addEventListener("focus", (e) => this.handleFocus(e, element)); - } - - updateAttachedElements() { - // Update all attached elements with current settings - this.attachedElements.forEach((element) => { - if (this.enabled) { - element.classList.add("vinakey-enabled"); - } else { - element.classList.remove("vinakey-enabled"); - this.clearComposition(element); - } - }); - } - - handleKeyDown(event, element) { - if (!this.enabled) return; - - // Handle special keys - if (event.key === "Escape") { - this.clearComposition(element); - return; - } - - if (event.key === "Enter" || event.key === "Tab") { - this.commitComposition(element); - return; - } - } - - handleInput(event, element) { - if (!this.enabled) return; - - const composition = this.compositionData.get(element); - if (!composition) return; - - // Get current text content - const currentText = this.getElementText(element); - const cursorPos = this.getCursorPosition(element); - - // Extract the word being typed (simple approach - could be improved) - const wordMatch = currentText.slice(0, cursorPos).match(/[a-zA-Z]+$/); - const currentWord = wordMatch ? wordMatch[0] : ""; - - if (currentWord && currentWord !== composition.buffer) { - // Process the word through Vietnamese input - const processedWord = this.inputMethod.processWord(currentWord); - - if (processedWord !== currentWord) { - // Replace the word in the text - const beforeWord = currentText.slice(0, cursorPos - currentWord.length); - const afterWord = currentText.slice(cursorPos); - const newText = beforeWord + processedWord + afterWord; - - // Update element - this.setElementText(element, newText); - this.setCursorPosition( - element, - beforeWord.length + processedWord.length, - ); - } - - composition.buffer = currentWord; - } - } - - handleFocus(event, element) { - if (!this.enabled) return; - - // Add visual indicator that VinaKey is active - element.classList.add("vinakey-active"); - } - - handleBlur(event, element) { - element.classList.remove("vinakey-active"); - this.clearComposition(element); - } - - clearComposition(element) { - const composition = this.compositionData.get(element); - if (composition) { - composition.buffer = ""; - composition.lastProcessedLength = 0; - } - } - - commitComposition(element) { - // For now, just clear the composition - this.clearComposition(element); - } - - getElementText(element) { - if ( - element.tagName.toLowerCase() === "textarea" || - element.tagName.toLowerCase() === "input" - ) { - return element.value; - } else { - return element.textContent || ""; - } - } - - setElementText(element, text) { - if ( - element.tagName.toLowerCase() === "textarea" || - element.tagName.toLowerCase() === "input" - ) { - element.value = text; - } else { - element.textContent = text; - } - } - - getCursorPosition(element) { - if ( - element.tagName.toLowerCase() === "textarea" || - element.tagName.toLowerCase() === "input" - ) { - return element.selectionStart || 0; - } else { - // For contenteditable, this is more complex - const selection = window.getSelection(); - return selection.anchorOffset || 0; - } - } - - setCursorPosition(element, position) { - if ( - element.tagName.toLowerCase() === "textarea" || - element.tagName.toLowerCase() === "input" - ) { - element.selectionStart = element.selectionEnd = position; - } else { - // For contenteditable - const range = document.createRange(); - const selection = window.getSelection(); - - if (element.firstChild) { - range.setStart( - element.firstChild, - Math.min(position, element.firstChild.textContent.length), - ); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } - } - } -} - -// Initialize when DOM is ready -if (document.readyState === "loading") { - document.addEventListener( - "DOMContentLoaded", - () => new VinaKeyContentScript(), - ); -} else { - new VinaKeyContentScript(); -} diff --git a/extension/manifest.json b/extension/manifest.json deleted file mode 100644 index 0355d66..0000000 --- a/extension/manifest.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "manifest_version": 3, - "name": "VinaKey - Vietnamese Input Method", - "version": "3.0.0", - "description": "Modern Vietnamese typing for every website - TELEX, VNI, VIQR support", - - "permissions": ["activeTab", "storage"], - - "background": { - "service_worker": "background/service-worker.js", - "type": "module" - }, - - "content_scripts": [ - { - "matches": [""], - "js": ["content/content-script.js"], - "run_at": "document_end" - } - ], - - "action": { - "default_popup": "popup/popup.html", - "default_title": "VinaKey Settings", - "default_icon": { - "16": "icons/icon-16.png", - "32": "icons/icon-32.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } - }, - - "icons": { - "16": "icons/icon-16.png", - "32": "icons/icon-32.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - }, - - "web_accessible_resources": [ - { - "resources": ["engine/*"], - "matches": [""] - } - ] -} diff --git a/extension/popup/popup.css b/extension/popup/popup.css deleted file mode 100644 index c71f3cc..0000000 --- a/extension/popup/popup.css +++ /dev/null @@ -1,243 +0,0 @@ -/* VinaKey Extension Popup Styles */ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #ffffff; - color: #333; - min-height: 400px; - width: 300px; -} - -.popup-container { - display: flex; - flex-direction: column; - height: 100%; -} - -/* Header */ -.popup-header { - display: flex; - align-items: center; - padding: 16px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - gap: 8px; -} - -.logo { - width: 24px; - height: 24px; -} - -.popup-header h1 { - font-size: 18px; - font-weight: 600; - flex: 1; -} - -.version { - font-size: 12px; - opacity: 0.8; - background: rgba(255, 255, 255, 0.2); - padding: 2px 6px; - border-radius: 8px; -} - -/* Main Content */ -.popup-content { - flex: 1; - padding: 16px; - display: flex; - flex-direction: column; - gap: 20px; -} - -.setting-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -.setting-label { - font-size: 13px; - font-weight: 600; - color: #555; -} - -/* Toggle Switch */ -.toggle-container { - display: flex; - align-items: center; - gap: 12px; -} - -.toggle { - position: relative; - display: inline-block; - width: 44px; - height: 24px; -} - -.toggle input { - opacity: 0; - width: 0; - height: 0; -} - -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - transition: 0.4s; - border-radius: 24px; -} - -.slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: 0.4s; - border-radius: 50%; -} - -input:checked + .slider { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -} - -input:checked + .slider:before { - transform: translateX(20px); -} - -.toggle-info { - display: flex; - flex-direction: column; -} - -.toggle-label { - font-size: 14px; - font-weight: 500; - color: #333; -} - -.toggle-status { - font-size: 12px; - color: #666; -} - -/* Select Dropdown */ -.setting-select { - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 6px; - font-size: 13px; - background: white; - cursor: pointer; -} - -.setting-select:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); -} - -/* Reference Grid */ -.reference-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.reference-item { - display: flex; - align-items: center; - gap: 4px; - padding: 6px 8px; - background: #f8f9fa; - border-radius: 4px; - font-size: 12px; -} - -.key { - font-family: "Monaco", "Menlo", monospace; - background: #e9ecef; - padding: 2px 4px; - border-radius: 3px; - color: #495057; - font-weight: 600; -} - -.arrow { - color: #6c757d; - font-size: 10px; -} - -.result { - font-weight: 500; - color: #333; -} - -/* Footer */ -.popup-footer { - padding: 12px 16px; - border-top: 1px solid #eee; - display: flex; - justify-content: space-between; - align-items: center; -} - -.btn { - padding: 6px 12px; - border: none; - border-radius: 4px; - font-size: 12px; - cursor: pointer; - text-decoration: none; - display: inline-block; -} - -.btn-secondary { - background: #f8f9fa; - color: #495057; - border: 1px solid #dee2e6; -} - -.btn-secondary:hover { - background: #e9ecef; -} - -.link { - font-size: 12px; - color: #667eea; - text-decoration: none; -} - -.link:hover { - text-decoration: underline; -} - -/* States */ -.disabled { - opacity: 0.6; -} - -.disabled .toggle-status { - color: #dc3545; -} - -.enabled .toggle-status { - color: #28a745; -} diff --git a/extension/popup/popup.html b/extension/popup/popup.html deleted file mode 100644 index e2fd6d0..0000000 --- a/extension/popup/popup.html +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - VinaKey Settings - - - - - - - - diff --git a/extension/popup/popup.js b/extension/popup/popup.js deleted file mode 100644 index 127b441..0000000 --- a/extension/popup/popup.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * VinaKey Extension Popup Script - */ - -class VinaKeyPopup { - constructor() { - this.settings = { - enabled: true, - inputMethod: "TELEX", - showTooltips: true, - }; - - this.initElements(); - this.loadSettings(); - this.attachEventListeners(); - } - - initElements() { - this.enabledToggle = document.getElementById("enabledToggle"); - this.inputMethodSelect = document.getElementById("inputMethodSelect"); - this.statusText = document.getElementById("statusText"); - this.openOptionsBtn = document.getElementById("openOptionsBtn"); - } - - async loadSettings() { - try { - // Get settings from background script - const settings = await this.getSettings(); - this.settings = { ...this.settings, ...settings }; - - // Update UI elements - this.updateUI(); - } catch (error) { - console.error("Failed to load settings:", error); - } - } - - getSettings() { - return new Promise((resolve) => { - chrome.runtime.sendMessage({ action: "getSettings" }, resolve); - }); - } - - updateSettings(newSettings) { - return new Promise((resolve) => { - chrome.runtime.sendMessage( - { - action: "updateSettings", - settings: newSettings, - }, - resolve, - ); - }); - } - - updateUI() { - // Update toggle - this.enabledToggle.checked = this.settings.enabled; - - // Update status text - this.statusText.textContent = this.settings.enabled - ? "Enabled" - : "Disabled"; - this.statusText.className = this.settings.enabled - ? "toggle-status enabled" - : "toggle-status disabled"; - - // Update input method - this.inputMethodSelect.value = this.settings.inputMethod; - - // Update container class - document.body.className = this.settings.enabled ? "enabled" : "disabled"; - } - - attachEventListeners() { - // Toggle enabled/disabled - this.enabledToggle.addEventListener("change", async (e) => { - this.settings.enabled = e.target.checked; - await this.updateSettings({ enabled: this.settings.enabled }); - this.updateUI(); - }); - - // Input method selection - this.inputMethodSelect.addEventListener("change", async (e) => { - this.settings.inputMethod = e.target.value; - await this.updateSettings({ inputMethod: this.settings.inputMethod }); - this.updateReferenceContent(); - }); - - // Open options page - this.openOptionsBtn.addEventListener("click", () => { - chrome.tabs.create({ - url: chrome.runtime.getURL("options/options.html"), - }); - window.close(); - }); - - // Keyboard shortcuts - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - window.close(); - } - - // Toggle with Ctrl+Shift+V - if (e.ctrlKey && e.shiftKey && e.key === "V") { - this.enabledToggle.checked = !this.enabledToggle.checked; - this.enabledToggle.dispatchEvent(new Event("change")); - } - }); - } - - updateReferenceContent() { - // Update quick reference based on selected input method - // This could be expanded to show method-specific shortcuts - const method = this.settings.inputMethod; - - // For now, always show TELEX shortcuts - // Could be enhanced to show VNI/VIQR specific shortcuts - console.log(`Reference updated for ${method} method`); - } -} - -// Initialize popup when DOM is loaded -document.addEventListener("DOMContentLoaded", () => { - new VinaKeyPopup(); -}); diff --git a/package.json b/package.json index 0d4b9eb..b6cd40b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@react-types/shared": "3.31.0", "@tailwindcss/postcss": "4.1.11", "@tailwindcss/vite": "4.1.11", + "anvim": "0.1.2", "clsx": "2.1.1", "framer-motion": "11.18.2", "react": "18.3.1", diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 1aaf99c..90a1dfa 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { Button } from "@heroui/button"; import { Card, CardBody } from "@heroui/card"; import { @@ -20,6 +20,10 @@ import EmojiPicker, { import { logger } from "@/utils/logger"; import { vietnameseInput, InputMethod } from "@/utils/vietnamese-input"; +// Auto-save configuration +const AUTOSAVE_KEY = "vinakey-editor-content"; +const AUTOSAVE_DELAY_MS = 1000; // 1 second debounce + // Extend window object to include OverType declare global { interface Window { @@ -43,6 +47,7 @@ export default function Editor({ }: EditorProps) { const editorRef = useRef(null); const isInitializingRef = useRef(true); + const autoSaveTimeoutRef = useRef(null); const [inputMethod, setInputMethod] = useState("AUTO"); const [isVietnameseEnabled, setIsVietnameseEnabled] = useState(true); const [editorInstance, setEditorInstance] = useState(null); @@ -61,6 +66,58 @@ export default function Editor({ // Responsive breakpoint detection for compact emoji modal const [isMobile, setIsMobile] = useState(false); + // Auto-save utilities + const saveToLocalStorage = useCallback((contentToSave: string) => { + try { + if (typeof window !== "undefined") { + localStorage.setItem(AUTOSAVE_KEY, contentToSave); + logger.debug("✓ Content auto-saved to localStorage"); + } + } catch (error) { + logger.error("Failed to save content to localStorage:", error); + } + }, []); + + const loadFromLocalStorage = useCallback((): string | null => { + try { + if (typeof window !== "undefined") { + return localStorage.getItem(AUTOSAVE_KEY); + } + } catch (error) { + logger.error("Failed to load content from localStorage:", error); + } + + return null; + }, []); + + const debouncedAutoSave = useCallback( + (contentToSave: string) => { + // Clear existing timeout + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + + // Set new timeout for debounced save + autoSaveTimeoutRef.current = setTimeout(() => { + saveToLocalStorage(contentToSave); + }, AUTOSAVE_DELAY_MS); + }, + [saveToLocalStorage], + ); + + // Content restoration on mount + useEffect(() => { + // Only restore if no initialContent was provided + if (!initialContent) { + const savedContent = loadFromLocalStorage(); + + if (savedContent) { + setContent(savedContent); + logger.info("✓ Restored content from localStorage"); + } + } + }, [initialContent, loadFromLocalStorage]); + useEffect(() => { if (typeof window === "undefined") return; const mq = window.matchMedia("(max-width: 640px)"); @@ -98,8 +155,15 @@ export default function Editor({ > **Ví dụ**: Hãy thử gõ "Toi yeu Viet Nam" với TELEX!`; + // Determine initial content: saved content > initial prop > sample content + const savedContent = loadFromLocalStorage(); + const effectiveInitialContent = + savedContent || + initialContent || + (isFirstVisit ? sampleContent : ""); + const instances = new OverTypeClass(editorRef.current, { - value: initialContent || (isFirstVisit ? sampleContent : ""), + value: effectiveInitialContent, placeholder: "Bắt đầu viết markdown với tiếng Việt...", toolbar: true, theme: theme === "dark" ? "cave" : "solar", @@ -110,6 +174,7 @@ export default function Editor({ onChange: (value: string) => { setContent(value); onContentChange?.(value); + debouncedAutoSave(value); }, }); @@ -128,10 +193,7 @@ export default function Editor({ setEditorInstance(instance); // Ensure React state mirrors editor initial value - const initialValue = - initialContent || (isFirstVisit ? sampleContent : ""); - - setContent(initialValue); + setContent(effectiveInitialContent); // Helper to attach listeners to the textarea when present const tryAttachTextarea = () => { @@ -147,6 +209,7 @@ export default function Editor({ const currentValue = textarea.value; setContent(currentValue); + debouncedAutoSave(currentValue); } catch {} }; @@ -215,9 +278,21 @@ export default function Editor({ return () => clearInterval(checkOverType); } - }, [initialContent, onContentChange]); - - // Removed offline persistence logic + }, [ + initialContent, + onContentChange, + loadFromLocalStorage, + debouncedAutoSave, + ]); + + // Cleanup auto-save timeout on unmount + useEffect(() => { + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + }; + }, []); useEffect(() => { vietnameseInput.setMethod(inputMethod); @@ -246,8 +321,8 @@ export default function Editor({ if (editorInstance) { editorInstance.setValue(""); setContent(""); + debouncedAutoSave(""); // Clear saved content too } - // No offline persistence to clear }; const insertAtCaret = (text: string) => { @@ -266,6 +341,7 @@ export default function Editor({ textarea.setSelectionRange(newPos, newPos); setContent(newValue); onContentChange?.(newValue); + debouncedAutoSave(newValue); const inputEvent = new Event("input", { bubbles: true }); (inputEvent as any)._vietnameseProcessed = true; @@ -296,8 +372,8 @@ export default function Editor({ if (editorInstance && clipboardText) { editorInstance.setValue(clipboardText); setContent(clipboardText); + debouncedAutoSave(clipboardText); logger.info("Content pasted from clipboard"); - // No offline persistence } } catch (err) { logger.error("Failed to paste content:", err); diff --git a/src/engine/methods/anvim.ts b/src/engine/methods/anvim.ts deleted file mode 100644 index 3a59a81..0000000 --- a/src/engine/methods/anvim.ts +++ /dev/null @@ -1,1085 +0,0 @@ -/** - * ANVIM - Direct TypeScript migration of AVIM.js - * Vietnamese Input Method Engine - * - * Based on AVIM JavaScript Vietnamese Input Method by Hieu Tran Dang - * Migrated to TypeScript while preserving exact logic and behavior - */ - -export interface AvimConfig { - method: number; // 0=AUTO, 1=TELEX, 2=VNI, 3=VIQR, 4=VIQR* - onOff: number; // 0=Off, 1=On - ckSpell: number; // 0=Off, 1=On - oldAccent: number; // 0: New way (oa`, oe`, uy`), 1: Old way (o`a, o`e, u`y) -} - -export type VietnameseInputMethod = - | "AUTO" - | "TELEX" - | "VNI" - | "VIQR" - | "VIQR*" - | "OFF"; - -export class AnvimEngine { - // Core AVIM properties and character maps - private whit: boolean = false; - - private skey: number[] = [ - 97, 226, 259, 101, 234, 105, 111, 244, 417, 117, 432, 121, 65, 194, 258, 69, - 202, 73, 79, 212, 416, 85, 431, 89, - ]; - private db1: number[] = [273, 272]; - private ds1: string[] = ["d", "D"]; - private os1: string[] = - "o,O,ơ,Ơ,ó,Ó,ò,Ò,ọ,Ọ,ỏ,Ỏ,õ,Õ,ớ,Ớ,ờ,Ờ,ợ,Ợ,ở,Ở,ỡ,Ỡ".split(","); - private ob1: string[] = - "ô,Ô,ô,Ô,ố,Ố,ồ,Ồ,ộ,Ộ,ổ,Ổ,ỗ,Ỗ,ố,Ố,ồ,Ồ,ộ,Ộ,ổ,Ổ,ỗ,Ỗ".split(","); - private mocs1: string[] = - "o,O,ô,Ô,u,U,ó,Ó,ò,Ò,ọ,Ọ,ỏ,Ỏ,õ,Õ,ú,Ú,ù,Ù,ụ,Ụ,ủ,Ủ,ũ,Ũ,ố,Ố,ồ,Ồ,ộ,Ộ,ổ,Ổ,ỗ,Ỗ".split( - ",", - ); - private mocb1: string[] = - "ơ,Ơ,ơ,Ơ,ư,Ư,ớ,Ớ,ờ,Ờ,ợ,Ợ,ở,Ở,ỡ,Ỡ,ứ,Ứ,ừ,Ừ,ự,Ự,ử,Ử,ữ,Ữ,ớ,Ớ,ờ,Ờ,ợ,Ợ,ở,Ở,ỡ,Ỡ".split( - ",", - ); - private trangs1: string[] = - "a,A,â,Â,á,Á,à,À,ạ,Ạ,ả,Ả,ã,Ã,ấ,Ấ,ầ,Ầ,ậ,Ậ,ẩ,Ẩ,ẫ,Ẫ".split(","); - private trangb1: string[] = - "ă,Ă,ă,Ă,ắ,Ắ,ằ,Ằ,ặ,Ặ,ẳ,Ẳ,ẵ,Ẵ,ắ,Ắ,ằ,Ằ,ặ,Ặ,ẳ,Ẳ,ẵ,Ẵ".split(","); - private as1: string[] = - "a,A,ă,Ă,á,Á,à,À,ạ,Ạ,ả,Ả,ã,Ã,ắ,Ắ,ằ,Ằ,ặ,Ặ,ẳ,Ẳ,ẵ,Ẵ,ế,Ế,ề,Ề,ệ,Ệ,ể,Ể,ễ,Ễ".split( - ",", - ); - private ab1: string[] = - "â,Â,â,Â,ấ,Ấ,ầ,Ầ,ậ,Ậ,ẩ,Ẩ,ẫ,Ẫ,ấ,Ấ,ầ,Ầ,ậ,Ậ,ẩ,Ẩ,ẫ,Ẫ,é,É,è,È,ẹ,Ẹ,ẻ,Ẻ,ẽ,Ẽ".split( - ",", - ); - private es1: string[] = "e,E,é,É,è,È,ẹ,Ẹ,ẻ,Ẻ,ẽ,Ẽ".split(","); - private eb1: string[] = "ê,Ê,ế,Ế,ề,Ề,ệ,Ệ,ể,Ể,ễ,Ễ".split(","); - private english: string = "ĐÂĂƠƯÊÔ"; - private lowen: string = "đâăơưêô"; - private arA: string[] = "á,à,ả,ã,ạ,a,Á,À,Ả,Ã,Ạ,A".split(","); - private mocrA: string[] = - "ó,ò,ỏ,õ,ọ,o,ú,ù,ủ,ũ,ụ,u,Ó,Ò,Ỏ,Õ,Ọ,O,Ú,Ù,Ủ,Ũ,Ụ,U".split(","); - private erA: string[] = "é,è,ẻ,ẽ,ẹ,e,É,È,Ẻ,Ẽ,Ẹ,E".split(","); - private orA: string[] = "ó,ò,ỏ,õ,ọ,o,Ó,Ò,Ỏ,Õ,Ọ,O".split(","); - private aA: string[] = "ấ,ầ,ẩ,ẫ,ậ,â,Ấ,Ầ,Ẩ,Ẫ,Ậ,Â".split(","); - private oA: string[] = "ố,ồ,ổ,ỗ,ộ,ô,Ố,Ồ,Ổ,Ỗ,Ộ,Ô".split(","); - private mocA: string[] = - "ớ,ờ,ở,ỡ,ợ,ơ,ứ,ừ,ử,ữ,ự,ư,Ớ,Ờ,Ở,Ỡ,Ợ,Ơ,Ứ,Ừ,Ử,Ữ,Ự,Ư".split(","); - private trangA: string[] = "ắ,ằ,ẳ,ẵ,ặ,ă,Ắ,Ằ,Ẳ,Ẵ,Ặ,Ă".split(","); - private eA: string[] = "ế,ề,ể,ễ,ệ,ê,Ế,Ề,Ể,Ễ,Ệ,Ê".split(","); - private skey2: string[] = - "a,a,a,e,e,i,o,o,o,u,u,y,A,A,A,E,E,I,O,O,O,U,U,Y".split(","); - - // Method specific keys (configured per input method in setupForMethod) - private DAWEO: string = ""; - private SFJRX: string = ""; - private S: string = ""; - private F: string = ""; - private J: string = ""; - private R: string = ""; - private X: string = ""; - private Z: string = ""; - private D: string = ""; - private moc: string = ""; - private trang: string = ""; - private A: string = ""; - private E: string = ""; - private O: string = ""; - private tw5: string = ""; - - private config: AvimConfig; - - constructor(config?: Partial) { - this.config = { - method: 1, - onOff: 1, - ckSpell: 1, - oldAccent: 0, - ...config, - }; - } - - // ===================== - // Low-level helpers - // ===================== - private fcc(x: number): string { - return String.fromCharCode(x); - } - private up(w: string): string { - return w.toUpperCase(); - } - private nan(w: any): boolean { - return isNaN(w) || w == "e"; - } - - private getSF(): string[] { - const sf: string[] = []; - - for (let x = 0; x < this.skey.length; x++) - sf[sf.length] = this.fcc(this.skey[x]); - - return sf; - } - - /** - * Return Unicode code points for tone-mark substitutions given a tone key. - * Mirrors AVIM tables for S/F/J/R/X. - */ - private retKC(k: string): number[] { - if (k == this.S) - return [ - 225, 7845, 7855, 233, 7871, 237, 243, 7889, 7899, 250, 7913, 253, 193, - 7844, 7854, 201, 7870, 205, 211, 7888, 7898, 218, 7912, 221, - ]; - if (k == this.F) - return [ - 224, 7847, 7857, 232, 7873, 236, 242, 7891, 7901, 249, 7915, 7923, 192, - 7846, 7856, 200, 7872, 204, 210, 7890, 7900, 217, 7914, 7922, - ]; - if (k == this.J) - return [ - 7841, 7853, 7863, 7865, 7879, 7883, 7885, 7897, 7907, 7909, 7921, 7925, - 7840, 7852, 7862, 7864, 7878, 7882, 7884, 7896, 7906, 7908, 7920, 7924, - ]; - if (k == this.R) - return [ - 7843, 7849, 7859, 7867, 7875, 7881, 7887, 7893, 7903, 7911, 7917, 7927, - 7842, 7848, 7858, 7866, 7874, 7880, 7886, 7892, 7902, 7910, 7916, 7926, - ]; - if (k == this.X) - return [ - 227, 7851, 7861, 7869, 7877, 297, 245, 7895, 7905, 361, 7919, 7929, 195, - 7850, 7860, 7868, 7876, 296, 213, 7894, 7904, 360, 7918, 7928, - ]; - - return []; - } - - /** - * Build the set of all code points that represent any tone marks excluding - * the current one (when provided). Used for stripping tones. - */ - private repSign(k: string | null): number[] { - const u: number[] = []; - - for (let a = 0; a < 5; a++) { - if (k == null || this.SFJRX.slice(a, a + 1) != this.up(k)) { - const temp = this.retKC(this.SFJRX.slice(a, a + 1)); - - for (let b = 0; b < temp.length; b++) u[u.length] = temp[b]; - } - } - - return u; - } - - /** - * Remove tone marks from a word, mapping marked characters back to base. - */ - private unV(w: string): string { - const u = this.repSign(null); - - for (let a = 1; a <= w.length; a++) { - for (let b = 0; b < u.length; b++) { - if (u[b] == w.charCodeAt(w.length - a)) { - w = - w.slice(0, w.length - a) + - this.fcc(this.skey[b % 24]) + - w.slice(w.length - a + 1); - } - } - } - - return w; - } - - /** - * Convert Vietnamese base characters to ASCII-like placeholders per AVIM. - */ - private unV2(w: string): string { - for (let a = 1; a <= w.length; a++) { - for (let b = 0; b < this.skey.length; b++) { - if (this.skey[b] == w.charCodeAt(w.length - a)) { - w = - w.slice(0, w.length - a) + - this.skey2[b] + - w.slice(w.length - a + 1); - } - } - } - - return w; - } - - /** - * Map DAWEO (A/E/O + horn/mark) combinations for VIQR/VNI paths. - */ - private DAWEOF(cc: string, k: string, g: number): number[] | false { - const ret: any[] = [g]; - const kA = [this.A, this.moc, this.trang, this.E, this.O]; - const ccA = [this.aA, this.mocA, this.trangA, this.eA, this.oA]; - const ccrA = [this.arA, this.mocrA, this.arA, this.erA, this.orA]; - - for (let a = 0; a < kA.length; a++) { - if (k == kA[a]) { - for (let z = 0; z < ccA[a].length; z++) { - if (cc == ccA[a][z]) ret[1] = ccrA[a][z]; - } - } - } - if (ret[1]) return ret as number[]; - - return false; - } - - /** - * Spell checker hook (disabled, preserved for compatibility). - */ - private ckspell(_w: string, _k: string): boolean { - return false; - } - - /** - * Core locator: determine position in word to apply transformation for key k - * given the method-specific vowel set sf. - */ - private findC( - w: string, - k: string, - sf: string[], - ): number | (number | string)[] | false { - const method = this.config.method; - - if ((method == 3 || method == 4) && w.slice(w.length - 1, w.length) == "\\") - return [1, k.charCodeAt(0)]; - - let str = ""; - let res: any; - let cc = ""; - let pc = ""; - let tE = ""; - const vowA: number[] = []; - const s = "ÂĂÊÔƠƯêâăơôư"; - let c = 0; - let dn = false; - const uw = this.up(w); - let tv: number; - let g: number; - const DAWEOFA = this.up( - this.aA.join() + - this.eA.join() + - this.mocA.join() + - this.trangA.join() + - this.oA.join() + - this.english, - ); - let h: number; - let uc: string; - - for (g = 0; g < sf.length; g++) { - if (this.nan(sf[g])) str += sf[g]; - else str += this.fcc(sf[g] as any); - } - - const uk = this.up(k); - const w2 = this.up(this.unV2(this.unV(w))); - const dont = "ƯA,ƯU".split(","); - - if (this.DAWEO.indexOf(uk) >= 0) { - if (uk == this.moc) { - if (w2.indexOf("UU") >= 0 && this.tw5 != dont[1]) { - if (w2.indexOf("UU") == w.length - 2) res = 2; - else return false; - } else if (w2.indexOf("UOU") >= 0) { - if (w2.indexOf("UOU") == w.length - 3) res = 2; - else return false; - } - } - - if (!res) { - for (g = 1; g <= w.length; g++) { - cc = w.slice(w.length - g, w.length - g + 1); - pc = this.up(w.slice(w.length - g - 1, w.length - g)); - uc = this.up(cc); - - for (h = 0; h < dont.length; h++) { - if (this.tw5 == dont[h] && this.tw5 == this.unV(pc + uc)) dn = true; - } - if (dn) { - dn = false; - continue; - } - - if (str.indexOf(uc) >= 0) { - if ( - (uk == this.moc && - this.unV(uc) == "U" && - this.up( - this.unV(w.slice(w.length - g + 1, w.length - g + 2)), - ) == "A") || - (uk == this.trang && this.unV(uc) == "A" && this.unV(pc) == "U") - ) { - if (this.unV(uc) == "U") tv = 1; - else tv = 2; - const ccc = this.up( - w.slice(w.length - g - tv, w.length - g - tv + 1), - ); - - if (ccc != "Q") res = g + tv - 1; - else if (uk == this.trang) res = g; - else if (this.moc != this.trang) return false; - } else { - res = g; - } - if (!this.whit || uw.indexOf("Ư") < 0 || uw.indexOf("W") < 0) break; - } else if (DAWEOFA.indexOf(uc) >= 0) { - if (uk == this.D) { - if (cc == "đ") res = [g, "d"]; - else if (cc == "Đ") res = [g, "D"]; - } else { - res = this.DAWEOF(cc, uk, g); - } - if (res) break; - } - } - } - } - - if (uk != this.Z && this.DAWEO.indexOf(uk) < 0) { - const tEC = this.retKC(uk); - - for (g = 0; g < tEC.length; g++) tE += this.fcc(tEC[g]); - } - - for (g = 1; g <= w.length; g++) { - if (this.DAWEO.indexOf(uk) < 0) { - cc = this.up(w.slice(w.length - g, w.length - g + 1)); - pc = this.up(w.slice(w.length - g - 1, w.length - g)); - if (str.indexOf(cc) >= 0) { - if (cc == "U") { - if (pc != "Q") { - c++; - vowA[vowA.length] = g; - } - } else if (cc == "I") { - if (pc != "G" || c <= 0) { - c++; - vowA[vowA.length] = g; - } - } else { - c++; - vowA[vowA.length] = g; - } - } else if (uk != this.Z) { - const signs = this.repSign(k); - - for (h = 0; h < signs.length; h++) { - if (signs[h] == w.charCodeAt(w.length - g)) { - if (this.ckspell(w, k)) return false; - - return [g, this.retKC(uk)[h % 24]]; - } - } - for (h = 0; h < this.retKC(uk).length; h++) { - if (this.retKC(uk)[h] == w.charCodeAt(w.length - g)) - return [g, this.fcc(this.skey[h])]; - } - } - } - } - - if (uk != this.Z && typeof res != "object") { - if (this.ckspell(w, k)) return false; - } - - if (this.DAWEO.indexOf(uk) < 0) { - for (g = 1; g <= w.length; g++) { - if ( - uk != this.Z && - s.indexOf(w.slice(w.length - g, w.length - g + 1)) >= 0 - ) - return g; - else if (tE.indexOf(w.slice(w.length - g, w.length - g + 1)) >= 0) { - for (h = 0; h < this.retKC(uk).length; h++) { - if ( - w.slice(w.length - g, w.length - g + 1).charCodeAt(0) == - this.retKC(uk)[h] - ) - return [g, this.fcc(this.skey[h])]; - } - } - } - } - - if (res) return res; - - if (c == 1 || uk == this.Z) return vowA[0]; - else if (c == 2) { - let v = 2; - - if (w.slice(w.length - 1) == " ") v = 3; - const ttt = this.up(w.slice(w.length - v, w.length)); - - if ( - this.config.oldAccent == 0 && - (ttt == "UY" || ttt == "OA" || ttt == "OE") - ) - return vowA[0]; - - let c2 = 0; - let fdconsonant: boolean; - const sc = "BCD" + this.fcc(272) + "GHKLMNPQRSTVX"; - const dc = "CH,GI,KH,NGH,GH,NG,NH,PH,QU,TH,TR".split(","); - - for (h = 1; h <= w.length; h++) { - fdconsonant = false; - for (g = 0; g < dc.length; g++) { - if ( - this.up( - w.slice(w.length - h - dc[g].length + 1, w.length - h + 1), - ).indexOf(dc[g]) >= 0 - ) { - c2++; - fdconsonant = true; - if (dc[g] != "NGH") h++; - else h += 2; - } - } - if (!fdconsonant) { - if (sc.indexOf(this.up(w.slice(w.length - h, w.length - h + 1))) >= 0) - c2++; - else break; - } - } - - if (c2 == 1 || c2 == 2) return vowA[0]; - else return vowA[1]; - } else if (c == 3) return vowA[1]; - else return false; - } - - /** - * Transform character at located position according to mapping tables. - */ - private tr(k: string, w: string, by: any[], sf: any[]): string { - const pos = this.findC(w, k, sf); - - if (pos) { - if (Array.isArray(pos) && pos[1]) { - const p0 = pos[0] as number; - const repl = - typeof pos[1] === "number" - ? this.fcc(pos[1] as number) - : (pos[1] as string); - - return w.slice(0, w.length - p0) + repl + w.slice(w.length - p0 + 1); - } else { - const pC = w.slice( - w.length - (pos as number), - w.length - (pos as number) + 1, - ); - const r = sf; - - for (let g = 0; g < r.length; g++) { - let cmp: any; - - if (this.nan(r[g]) || r[g] == "e") cmp = pC; - else cmp = pC.charCodeAt(0); - if (cmp == r[g]) { - let c: any; - - if (!this.nan(by[g])) c = by[g]; - else c = by[g].charCodeAt(0); - - return ( - w.slice(0, w.length - (pos as number)) + - this.fcc(c) + - w.slice(w.length - (pos as number) + 1) - ); - } - } - } - } - - return w; - } - - /** - * Return Unicode code point to replace character at pos with tone k. - */ - private retUni(w: string, k: string, pos: number): number { - const u = this.retKC(this.up(k)); - let uC = 0, - lC = 0; - const c = w.charCodeAt(w.length - pos); - const t = this.fcc(c); - - for (let a = 0; a < this.skey.length; a++) { - if (this.skey[a] == c) { - if (a < 12) { - lC = a; - uC = a + 12; - } else { - lC = a - 12; - uC = a; - } - if (t != this.up(t)) return u[lC]; - - return u[uC]; - } - } - - return c; - } - - /** - * Single replacement: apply tone or diacritic for one letter. - */ - private sr(w: string, k: string): string { - const sf = this.getSF(); - const pos = this.findC(w, k, sf); - - if (pos) { - if (Array.isArray(pos) && pos[1]) { - const p0 = pos[0] as number; - const repl = - typeof pos[1] === "number" - ? this.fcc(pos[1] as number) - : (pos[1] as string); - - return w.slice(0, w.length - p0) + repl + w.slice(w.length - p0 + 1); - } else { - const c = this.retUni(w, k, pos as number); - - return ( - w.slice(0, w.length - (pos as number)) + - this.fcc(c) + - w.slice(w.length - (pos as number) + 1) - ); - } - } - - return w; - } - - /** - * Configure method-specific keys and markers for TELEX/VNI/VIQR variants. - */ - private setupForMethod(a: string[]): void { - const method = this.config.method; - - if (method == 2 || (method == 0 && a[0] == "9")) { - this.DAWEO = "6789"; - this.SFJRX = "12534"; - this.S = "1"; - this.F = "2"; - this.J = "5"; - this.R = "3"; - this.X = "4"; - this.Z = "0"; - this.D = "9"; - this.moc = "7"; - this.trang = "8"; - this.A = "^"; - this.E = "^"; - this.O = "^"; - } else if (method == 3 || (method == 0 && a[4] == "+")) { - this.DAWEO = "^+(D"; - this.SFJRX = "'`.?~"; - this.S = "'"; - this.F = "`"; - this.J = "."; - this.R = "?"; - this.X = "~"; - this.Z = "-"; - this.D = "D"; - this.moc = "+"; - this.trang = "("; - this.A = "^"; - this.E = "^"; - this.O = "^"; - } else if (method == 4 || (method == 0 && a[4] == "*")) { - this.DAWEO = "^*(D"; - this.SFJRX = "'`.?~"; - this.S = "'"; - this.F = "`"; - this.J = "."; - this.R = "?"; - this.X = "~"; - this.Z = "-"; - this.D = "D"; - this.moc = "*"; - this.trang = "("; - this.A = "^"; - this.E = "^"; - this.O = "^"; - } else { - this.SFJRX = "SFJRX"; - this.DAWEO = "DAWEO"; - this.D = "D"; - this.S = "S"; - this.F = "F"; - this.J = "J"; - this.R = "R"; - this.X = "X"; - this.Z = "Z"; - this.trang = "W"; - this.moc = "W"; - this.A = "A"; - this.E = "E"; - this.O = "O"; - } - } - - /** - * Main AVIM transformation for a prefix w and typed key k under mapping a. - */ - private main(w: string, k: string, a: string[]): string { - const uk = this.up(k); - const bya = [ - this.db1, - this.ab1, - this.eb1, - this.ob1, - this.mocb1, - this.trangb1, - ]; - const t = "d,D,a,A,a,A,o,O,u,U,e,E,o,O".split(","); - const sfa = [ - this.ds1, - this.as1, - this.es1, - this.os1, - this.mocs1, - this.trangs1, - ]; - let by: any[] = []; - let sf: any[] = []; - - this.setupForMethod(a); - - let got = false; - - if (this.SFJRX.indexOf(uk) >= 0) { - const ret = this.sr(w, k); - - got = true; - if (ret !== w) return ret; - } else if (uk == this.Z) { - sf = this.repSign(null); - for (let h = 0; h < this.english.length; h++) { - sf[sf.length] = this.lowen.charCodeAt(h); - sf[sf.length] = this.english.charCodeAt(h); - } - for (let h = 0; h < 5; h++) { - for (let g = 0; g < this.skey.length; g++) { - by[by.length] = this.skey[g]; - } - } - for (let h = 0; h < t.length; h++) by[by.length] = t[h]; - got = true; - } else { - for (let h = 0; h < a.length; h++) { - if (a[h] == uk) { - got = true; - by = by.concat(bya[h]); - sf = sf.concat(sfa[h]); - } - } - } - - if (uk == this.moc) this.whit = true; - if (!got) return w; - if (this.DAWEO.indexOf(uk) >= 0 || this.Z.indexOf(uk) >= 0) - return this.tr(k, w, by, sf); - - return w; - } - - /** Utility: does word contain any character from set? */ - private hasCharFromSet(word: string, set: string[]): boolean { - for (const ch of set) { - if (word.indexOf(ch) >= 0) return true; - } - - return false; - } - - /** Utility: does word contain any tone mark? */ - private hasTone(word: string): boolean { - const all = [ - ...this.retKC("S"), - ...this.retKC("F"), - ...this.retKC("R"), - ...this.retKC("X"), - ...this.retKC("J"), - ]; - - for (const code of all) { - if (word.indexOf(this.fcc(code)) >= 0) return true; - } - - return false; - } - - /** Map an A/E/O/W/D key to its diacritic character set for toggle detection. */ - private diacriticSetForKey(keyUpper: string): string[] | null { - if (keyUpper === "E") return this.eb1; - if (keyUpper === "A") return this.ab1; - if (keyUpper === "O") return this.ob1; - if (keyUpper === "W") return this.mocb1.concat(this.trangb1); - if (keyUpper === "D") return ["đ", "Đ"]; - - return null; - } - - // ===================== - // Public API - word/keystroke processing - // ===================== - /** Process a full word by simulating keystrokes for each character. */ - processWord(word: string): string { - if (!word || word.length === 0) return word; - if (this.config.onOff === 0) return word; - - const telex = "D,A,E,O,W,W".split(","); - const vni = "9,6,6,6,7,8".split(","); - const viqr = "D,^,^,^,+,(".split(","); - const viqr2 = "D,^,^,^,*,(".split(","); - - let uni: string[] = []; - let uni2: string[] = []; - let uni3: string[] = []; - let uni4: string[] = []; - - if (this.config.method == 0) { - // AUTO - const arr: string[][] = []; - const check = [true, true, true, true]; - const value1 = [telex, vni, viqr, viqr2]; - - for (let a = 0; a < check.length; a++) { - if (check[a]) arr[arr.length] = value1[a]; - } - for (let a = 0; a < arr.length; a++) { - if (a === 0) uni = arr[a]; - if (a === 1) uni2 = arr[a]; - if (a === 2) uni3 = arr[a]; - if (a === 3) uni4 = arr[a]; - } - if (!uni.length) return word; - } else if (this.config.method == 1) { - uni = telex; - } else if (this.config.method == 2) { - uni = vni; - } else if (this.config.method == 3) { - uni = viqr; - } else if (this.config.method == 4) { - uni = viqr2; - } - - let currentWord = word; - - for (let i = 1; i <= currentWord.length; i++) { - const w = currentWord.substring(0, i); - const k = currentWord.substring(i - 1, i); - let processed = this.main(w, k, uni); - - if (processed !== w) { - currentWord = processed + currentWord.substring(i); - continue; - } - if (this.config.method === 0) { - if (uni2.length) { - processed = this.main(w, k, uni2); - if (processed !== w) { - currentWord = processed + currentWord.substring(i); - continue; - } - } - if (uni3.length) { - processed = this.main(w, k, uni3); - if (processed !== w) { - currentWord = processed + currentWord.substring(i); - continue; - } - } - if (uni4.length) { - processed = this.main(w, k, uni4); - if (processed !== w) { - currentWord = processed + currentWord.substring(i); - continue; - } - } - } - } - - return currentWord; - } - - /** - * Process a single keystroke applied to the current prefix (closer to AVIM's - * real-time behavior). This method also implements two ergonomic - * improvements: - * - Incremental uo + w => ươ composition preserving case. - * - Long-distance horn composition when uo is followed by consonants. - * - * These improvements are designed to be backward compatible with AVIM. - */ - processWithKey(prefix: string, key: string): string { - if (this.config.onOff === 0) return prefix + key; - const telex = "D,A,E,O,W,W".split(","); - const vni = "9,6,6,6,7,8".split(","); - const viqr = "D,^,^,^,+,(".split(","); - const viqr2 = "D,^,^,^,*,(".split(","); - - let uni: string[] = []; - let uni2: string[] = []; - let uni3: string[] = []; - let uni4: string[] = []; - - if (this.config.method == 0) { - // AUTO - const arr: string[][] = []; - const value1 = [telex, vni, viqr, viqr2]; - - for (let a = 0; a < value1.length; a++) arr[arr.length] = value1[a]; - for (let a = 0; a < arr.length; a++) { - if (a === 0) uni = arr[a]; - if (a === 1) uni2 = arr[a]; - if (a === 2) uni3 = arr[a]; - if (a === 3) uni4 = arr[a]; - } - } else if (this.config.method == 1) { - uni = telex; - } else if (this.config.method == 2) { - uni = vni; - } else if (this.config.method == 3) { - uni = viqr; - } else if (this.config.method == 4) { - uni = viqr2; - } - - // Special incremental composition: uo + w/uow -> ươ (preserve case) - if (this.config.method === 1 || this.config.method === 0) { - const last2 = prefix.slice(-2); - - if (/uo/i.test(last2) && key.toLowerCase() === "w") { - const u = last2[0]; - const o = last2[1]; - const isUpperU = u === u.toUpperCase(); - const isUpperO = o === o.toUpperCase(); - const composed = (isUpperU ? "Ư" : "ư") + (isUpperO ? "Ơ" : "ơ"); - - return prefix.slice(0, -2) + composed; - } - } - - // Long-distance horn composition: if key is 'w' and word ends with consonants after 'uo', map to 'ươ' - if ( - (this.config.method === 1 || this.config.method === 0) && - key.toLowerCase() === "w" - ) { - // Define a simple Vietnamese vowel class - const vowelClass = /[aeiouyâăêôơưAEIOUYÂĂÊÔƠƯ]/; - - // Find last 'uo' before trailing consonants - for (let i = prefix.length - 2; i >= 1; i--) { - if ( - prefix[i - 1].toLowerCase() === "u" && - prefix[i].toLowerCase() === "o" - ) { - // Ensure from i+1 to end there are no vowels (only consonants), so 'uo' is the last vowel cluster - let hasVowelAfter = false; - - for (let j = i + 1; j < prefix.length; j++) { - if (vowelClass.test(prefix[j])) { - hasVowelAfter = true; - break; - } - } - if (!hasVowelAfter) { - const U = prefix[i - 1]; - const O = prefix[i]; - const isUUpper = U === U.toUpperCase(); - const isOUpper = O === O.toUpperCase(); - const composed = (isUUpper ? "Ư" : "ư") + (isOUpper ? "Ơ" : "ơ"); - - return ( - prefix.substring(0, i - 1) + composed + prefix.substring(i + 1) - ); - } - } - } - } - - // Call main with prefix (text before key), as in AVIM - const before = prefix; - let out = this.main(prefix, key, uni); - - if (out !== before) { - const keyUpper = key.toUpperCase(); - - // Tone toggle-off: previously had tone, now removed -> append key (preserve case) - if ("SFJRX".indexOf(keyUpper) >= 0) { - if (this.hasTone(before) && !this.hasTone(out)) return out + key; - - return out; - } - // Diacritic toggle-off: previously had respective diacritic, now removed -> append key (preserve case) - const set = this.diacriticSetForKey(keyUpper); - - if (set) { - if (this.hasCharFromSet(before, set) && !this.hasCharFromSet(out, set)) - return out + key; - - return out; - } - - return out; - } - if (this.config.method === 0) { - if (uni2.length) { - out = this.main(prefix, key, uni2); - if (out !== before) { - const keyUpper = key.toUpperCase(); - - if ("SFJRX".indexOf(keyUpper) >= 0) { - if (this.hasTone(before) && !this.hasTone(out)) return out + key; - - return out; - } - const set = this.diacriticSetForKey(keyUpper); - - if (set) { - if ( - this.hasCharFromSet(before, set) && - !this.hasCharFromSet(out, set) - ) - return out + key; - - return out; - } - - return out; - } - } - if (uni3.length) { - out = this.main(prefix, key, uni3); - if (out !== before) { - const keyUpper = key.toUpperCase(); - - if ("SFJRX".indexOf(keyUpper) >= 0) { - if (this.hasTone(before) && !this.hasTone(out)) return out + key; - - return out; - } - const set = this.diacriticSetForKey(keyUpper); - - if (set) { - if ( - this.hasCharFromSet(before, set) && - !this.hasCharFromSet(out, set) - ) - return out + key; - - return out; - } - - return out; - } - } - if (uni4.length) { - out = this.main(prefix, key, uni4); - if (out !== before) { - const keyUpper = key.toUpperCase(); - - if ("SFJRX".indexOf(keyUpper) >= 0) { - if (this.hasTone(before) && !this.hasTone(out)) return out + key; - - return out; - } - const set = this.diacriticSetForKey(keyUpper); - - if (set) { - if ( - this.hasCharFromSet(before, set) && - !this.hasCharFromSet(out, set) - ) - return out + key; - - return out; - } - - return out; - } - } - } - // If no change, decide whether to append key literally - const keyUpper = key.toUpperCase(); - const diacriticSet = this.diacriticSetForKey(keyUpper); - - if (diacriticSet) { - // appending diacritic when no transformation should just add the literal letter (preserve case) - return prefix + key; - } - // tone markers that didn't transform should append literally (preserve case) - if ("SFJRX".indexOf(keyUpper) >= 0) return prefix + key; - - return prefix + key; - } - - // API - setMethod(method: number): void { - this.config.method = method; - this.config.onOff = method === -1 ? 0 : 1; - } - setMethodByString(method: VietnameseInputMethod): void { - const methodMap: Record = { - OFF: -1, - AUTO: 0, - TELEX: 1, - VNI: 2, - VIQR: 3, - "VIQR*": 4, - }; - - this.setMethod(methodMap[method] ?? 1); - } - setEnabled(enabled: boolean): void { - this.config.onOff = enabled ? 1 : 0; - } - getMethod(): number { - return this.config.method; - } - getMethodString(): VietnameseInputMethod { - const map: Record = { - [-1]: "OFF", - [0]: "AUTO", - [1]: "TELEX", - [2]: "VNI", - [3]: "VIQR", - [4]: "VIQR*", - }; - - return map[this.config.method] ?? "TELEX"; - } - isEnabled(): boolean { - return this.config.onOff === 1; - } -} - -export default function anvim(input: string): string { - const engine = new AnvimEngine(); - let out = ""; - - for (let i = 0; i < input.length; i++) { - out = engine.processWithKey(out, input[i]); - } - - return out; -} - -export const anvimEngine = new AnvimEngine(); diff --git a/src/utils/vietnamese-input.ts b/src/utils/vietnamese-input.ts index 388097d..c8b8826 100644 --- a/src/utils/vietnamese-input.ts +++ b/src/utils/vietnamese-input.ts @@ -7,10 +7,8 @@ */ export type InputMethod = "OFF" | "AUTO" | "TELEX" | "VNI" | "VIQR"; -import { - AnvimEngine, - type VietnameseInputMethod, -} from "@/engine/methods/anvim"; +import { AnvimEngine } from "anvim"; + import { logger } from "@/utils/logger"; class VietnameseInput { @@ -118,9 +116,7 @@ class VietnameseInput { return; } - this.anvimEngine.setMethodByString( - this.currentMethod as VietnameseInputMethod, - ); + this.anvimEngine.setMethodByString(this.currentMethod); const processedWord = this.anvimEngine .processWithKey(prefix.normalize("NFC"), lastKey) .normalize("NFC"); diff --git a/tests/anvim.test.ts b/tests/anvim.test.ts index 61e6f29..f558920 100644 --- a/tests/anvim.test.ts +++ b/tests/anvim.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; -import anvim, { AnvimEngine } from "../src/engine/methods/anvim"; +import anvim, { AnvimEngine } from "anvim"; describe("ANVIM - AVIM.js Compatible TELEX", () => { describe("Basic Vowel Transformations", () => { @@ -186,9 +186,10 @@ describe("ANVIM - AVIM.js Compatible TELEX", () => { describe("Z-Clear Functionality", () => { it("should clear all diacritics with z", () => { - expect(anvim("tiếngz")).toBe("tiêngz"); - expect(anvim("việtz")).toBe("viêtz"); - expect(anvim("nhàz")).toBe("nhaz"); + // anvim@0.1.2 consumes 'z' when clearing diacritics + expect(anvim("tiếngz")).toBe("tiêng"); + expect(anvim("việtz")).toBe("viêt"); + expect(anvim("nhàz")).toBe("nha"); }); }); @@ -205,8 +206,8 @@ describe("ANVIM - AVIM.js Compatible TELEX", () => { describe("Word Boundaries", () => { it("should process multiple words separately", () => { - expect(anvim("hocj sinhh")).toBe("học sinh"); - expect(anvim("vieejt namm")).toBe("việt nam"); + expect(anvim("hocj sinh")).toBe("học sinh"); + expect(anvim("vieejt nam")).toBe("việt nam"); expect(anvim("ddaij hocj")).toBe("đại học"); }); }); @@ -225,11 +226,9 @@ describe("ANVIM - AVIM.js Compatible TELEX", () => { }); it("should handle incremental processing", () => { - const out1 = engine.processWithKey("Tôi học ", "t"); - expect(out1).toBe("Tôi học t"); - - const out2 = engine.processWithKey("Tôi học tiees", "n"); - expect(out2).toBe("Tôi học tiến"); + let out = "Tôi học "; + for (const ch of "tieengs") out = engine.processWithKey(out, ch); + expect(out).toBe("Tôi học tiếng"); }); it("should respect enabled/disabled state", () => { diff --git a/vite.config.ts b/vite.config.ts index 7ad0dc9..44721f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,5 +16,8 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["./src/test/setup.ts"], globals: true, + deps: { + inline: ["anvim"], + }, }, }); diff --git a/yarn.lock b/yarn.lock index 30878ee..98e358b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2444,6 +2444,11 @@ ansi-styles@^6.0.0, ansi-styles@^6.2.1: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== +anvim@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/anvim/-/anvim-0.1.2.tgz#d81dda311116287444fc8f6cdfb250d738741cfe" + integrity sha512-PAtWBDzW6s27gCNWFmXdW1ZTh7+DvFAQ59G3EoP/hkK0jBPkvD+qu3s6YUyUEUbzWYd/PUYfRHlEYw3yaGd2iQ== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"