main
js 134 lines 3.79 KB
Raw
1 // Message Action Buttons - DOM helpers for message action buttons
2
3 import { ICON_SELECTOR, setIconName } from "/js/icons.js";
4
5 const ACTION_ICON_MAP = {
6 detail: "open_in_full",
7 speak: "volume_up",
8 copy: "content_copy",
9 };
10
11 const ACTION_LABELS = {
12 detail: "View details",
13 speak: "Speak",
14 copy: "Copy",
15 };
16
17 function resolveActionIcon(icon) {
18 if (!icon) return "";
19 return ACTION_ICON_MAP[icon] || icon;
20 }
21
22 function buildActionLabel(icon, text) {
23 const baseLabel = ACTION_LABELS[icon] || text || icon;
24 if (text && ACTION_LABELS[icon]) return `${ACTION_LABELS[icon]} ${text}`;
25 return baseLabel;
26 }
27
28 /**
29 * Copy text to clipboard with fallback for non-secure contexts
30 */
31 export async function copyToClipboard(text) {
32 if (navigator.clipboard && window.isSecureContext) {
33 await navigator.clipboard.writeText(text);
34 } else {
35 // Fallback for local dev / non-secure contexts
36 const textarea = document.createElement("textarea");
37 textarea.value = text;
38 textarea.style.cssText = "position:fixed;left:-9999px";
39 document.body.appendChild(textarea);
40 textarea.select();
41 document.execCommand("copy");
42 document.body.removeChild(textarea);
43 }
44 }
45
46 /**
47 * Show visual feedback on a button (success/error state)
48 */
49 export function showButtonFeedback(button, success, originalIcon) {
50 const icon = button.querySelector(ICON_SELECTOR);
51 if (!icon) return;
52
53 setIconName(icon, success ? "check" : "error");
54 button.classList.add(success ? "success" : "error");
55
56 setTimeout(() => {
57 setIconName(icon, originalIcon);
58 button.classList.remove("success", "error");
59 }, 1000);
60 }
61
62 export function syncActionButtons(container, actionButtons = []) {
63 const previous = container.__managedActionButtons || [];
64 const next = actionButtons.filter(Boolean).map((button, index) => {
65 const existing = previous[index];
66 if (
67 existing?.isConnected &&
68 existing.dataset.actionKey === button.dataset.actionKey
69 ) {
70 existing.__actionHandler = button.__actionHandler;
71 return existing;
72 }
73 existing?.remove();
74 return button;
75 });
76 const anchor = [...container.children].find(
77 (child) =>
78 !previous.includes(child) && !child.classList.contains("expand-btn"),
79 );
80
81 previous.slice(next.length).forEach((button) => button.remove());
82 next.forEach((button) => container.insertBefore(button, anchor || null));
83 container.__managedActionButtons = next;
84 }
85
86 /**
87 * Create action button element
88 *
89 * @param {string} icon
90 * @param {string} [text]
91 * @param {(() => (any | Promise<any>)) | null} [handler]
92 * @returns {HTMLButtonElement}
93 */
94 export function createActionButton(icon, text = "", handler = null) {
95 const iconName = resolveActionIcon(icon);
96
97 const button = document.createElement("button");
98 button.type = "button";
99 button.className = `action-button action-${icon}`;
100 button.dataset.actionKey = `${icon}:${text}`;
101 button.__actionHandler = handler;
102 const label = buildActionLabel(icon, text);
103 if (label) {
104 button.setAttribute("aria-label", label);
105 button.setAttribute("title", label);
106 }
107
108 if (iconName) {
109 const iconElement = document.createElement("x-icon");
110 iconElement.name = iconName;
111 button.appendChild(iconElement);
112 } else if (text) {
113 button.textContent = text;
114 }
115
116 button.addEventListener("click", async (event) => {
117 event.stopPropagation();
118 if (typeof button.__actionHandler !== "function") return;
119 const shouldShowFeedback = Boolean(iconName); // icon === "copy" || icon === "speak";
120 try {
121 await button.__actionHandler();
122 if (shouldShowFeedback) {
123 showButtonFeedback(button, true, iconName);
124 }
125 } catch (err) {
126 console.error("Action button failed:", err);
127 if (shouldShowFeedback) {
128 showButtonFeedback(button, false, iconName);
129 }
130 }
131 });
132
133 return button;
134 }