@catandbox/schrodinger-web-adapter 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,3 +4,37 @@
4
4
  */
5
5
  export declare function setInnerHtml(el: HTMLElement, html: string): void;
6
6
  export declare function replaceOuterHtml(el: HTMLElement, html: string): void;
7
+ /**
8
+ * Escape HTML special characters to prevent XSS.
9
+ */
10
+ export declare function escapeHtml(text: string): string;
11
+ /**
12
+ * Convert plain text with formatting markers to safe HTML.
13
+ * Supported markers (must not span newlines):
14
+ * **text** → <strong>text</strong>
15
+ * __text__ → <u>text</u>
16
+ *
17
+ * HTML is escaped first so markers in user input cannot inject arbitrary tags.
18
+ */
19
+ export declare function formatBodyText(text: string): string;
20
+ /**
21
+ * Wrap the textarea's selected text with the given marker (e.g. "**" or "__").
22
+ * Toggles off if the selected text is already wrapped with that marker.
23
+ * If nothing is selected, places the cursor between two fresh markers.
24
+ */
25
+ export declare function applyFormat(textarea: HTMLTextAreaElement, marker: string): void;
26
+ /**
27
+ * Render a textarea wrapped in a formatting toolbar.
28
+ */
29
+ export declare function renderFormattingField(options: {
30
+ id: string;
31
+ rows: number;
32
+ required?: boolean;
33
+ placeholder?: string;
34
+ extraStyle?: string;
35
+ }): string;
36
+ /**
37
+ * Attach focus ring, hover states, keyboard shortcuts, and formatting button
38
+ * listeners to a field rendered by renderFormattingField().
39
+ */
40
+ export declare function attachFormattingField(container: HTMLElement, id: string): void;
@@ -13,3 +13,181 @@ export function replaceOuterHtml(el, html) {
13
13
  const frag = document.createRange().createContextualFragment(html);
14
14
  el.replaceWith(frag);
15
15
  }
16
+ /**
17
+ * Escape HTML special characters to prevent XSS.
18
+ */
19
+ export function escapeHtml(text) {
20
+ const div = document.createElement("div");
21
+ div.textContent = text;
22
+ return div.innerHTML;
23
+ }
24
+ /**
25
+ * Convert plain text with formatting markers to safe HTML.
26
+ * Supported markers (must not span newlines):
27
+ * **text** → <strong>text</strong>
28
+ * __text__ → <u>text</u>
29
+ *
30
+ * HTML is escaped first so markers in user input cannot inject arbitrary tags.
31
+ */
32
+ export function formatBodyText(text) {
33
+ const escaped = escapeHtml(text);
34
+ return escaped
35
+ .replace(/\*\*((?:[^*\n]|\*(?!\*))+)\*\*/g, "<strong>$1</strong>")
36
+ .replace(/__((?:[^_\n]|_(?!_))+)__/g, "<u>$1</u>");
37
+ }
38
+ /**
39
+ * Wrap the textarea's selected text with the given marker (e.g. "**" or "__").
40
+ * Toggles off if the selected text is already wrapped with that marker.
41
+ * If nothing is selected, places the cursor between two fresh markers.
42
+ */
43
+ export function applyFormat(textarea, marker) {
44
+ const start = textarea.selectionStart;
45
+ const end = textarea.selectionEnd;
46
+ const value = textarea.value;
47
+ const selected = value.slice(start, end);
48
+ const m = marker.length;
49
+ // Toggle off: selected text itself is already wrapped (e.g. user selected "**foo**")
50
+ if (selected.length >= m * 2 + 1 && selected.startsWith(marker) && selected.endsWith(marker)) {
51
+ const inner = selected.slice(m, selected.length - m);
52
+ textarea.value = value.slice(0, start) + inner + value.slice(end);
53
+ textarea.setSelectionRange(start, start + inner.length);
54
+ textarea.focus();
55
+ return;
56
+ }
57
+ // Apply formatting
58
+ const replacement = `${marker}${selected}${marker}`;
59
+ textarea.value = value.slice(0, start) + replacement + value.slice(end);
60
+ // If nothing was selected: land cursor between the two markers so user can type
61
+ // If text was selected: land cursor after the closing marker
62
+ const cursorPos = selected.length === 0 ? start + m : end + m * 2;
63
+ textarea.setSelectionRange(cursorPos, cursorPos);
64
+ textarea.focus();
65
+ }
66
+ const TOOLBAR_BTN_STYLE = [
67
+ "background:var(--p-color-bg-surface, #fff)",
68
+ "border:1px solid var(--p-color-border-subdued, #c9cccf)",
69
+ "padding:3px 10px",
70
+ "cursor:pointer",
71
+ "border-radius:5px",
72
+ "font-size:13px",
73
+ "font-family:inherit",
74
+ "line-height:1.5",
75
+ "color:var(--p-color-text, #202223)",
76
+ "transition:background 0.1s, border-color 0.1s, box-shadow 0.1s",
77
+ "user-select:none",
78
+ ].join(";");
79
+ const TOOLBAR_WRAPPER_STYLE = [
80
+ "border:1px solid var(--p-color-border, #c9cccf)",
81
+ "border-radius:8px",
82
+ "overflow:hidden",
83
+ "background:var(--p-color-bg-surface, #fff)",
84
+ "transition:border-color 0.15s ease, box-shadow 0.15s ease",
85
+ ].join(";");
86
+ const TOOLBAR_BAR_STYLE = [
87
+ "padding:6px 10px",
88
+ "border-bottom:1px solid var(--p-color-border, #c9cccf)",
89
+ "background:var(--p-color-bg-surface-secondary, #f6f6f7)",
90
+ "display:flex",
91
+ "gap:6px",
92
+ "align-items:center",
93
+ ].join(";");
94
+ const TEXTAREA_INNER_STYLE = [
95
+ "width:100%",
96
+ "padding:10px 12px",
97
+ "border:none",
98
+ "outline:none",
99
+ "border-radius:0",
100
+ "font-family:inherit",
101
+ "font-size:14px",
102
+ "line-height:1.5",
103
+ "box-sizing:border-box",
104
+ "background:transparent",
105
+ ].join(";");
106
+ /**
107
+ * Render a textarea wrapped in a formatting toolbar.
108
+ */
109
+ export function renderFormattingField(options) {
110
+ const { id, rows, required = false, placeholder = "", extraStyle = "" } = options;
111
+ const wrapperId = `${id}-wrapper`;
112
+ const boldId = `${id}-fmt-bold`;
113
+ const ulId = `${id}-fmt-ul`;
114
+ return `
115
+ <div id="${wrapperId}" style="${TOOLBAR_WRAPPER_STYLE}">
116
+ <div style="${TOOLBAR_BAR_STYLE}" role="toolbar" aria-label="Text formatting">
117
+ <button type="button" id="${boldId}" data-fmt="**"
118
+ title="Bold (Ctrl+B)" aria-label="Bold"
119
+ style="${TOOLBAR_BTN_STYLE}"><strong>B</strong></button>
120
+ <button type="button" id="${ulId}" data-fmt="__"
121
+ title="Underline (Ctrl+U)" aria-label="Underline"
122
+ style="${TOOLBAR_BTN_STYLE}"><u style="text-underline-offset:2px;">U</u></button>
123
+ <span style="width:1px; height:16px; background:var(--p-color-border-subdued, #c9cccf); margin:0 2px; display:inline-block; align-self:center; flex-shrink:0;"></span>
124
+ <span style="font-size:11px; color:var(--p-color-text-subdued, #6d7175); line-height:1;">
125
+ Ctrl+B &nbsp;·&nbsp; Ctrl+U
126
+ </span>
127
+ </div>
128
+ <textarea id="${id}" rows="${rows}"${required ? " required" : ""}
129
+ style="${TEXTAREA_INNER_STYLE}${extraStyle ? "; " + extraStyle : ""}"
130
+ placeholder="${placeholder}"></textarea>
131
+ </div>
132
+ `;
133
+ }
134
+ /**
135
+ * Attach focus ring, hover states, keyboard shortcuts, and formatting button
136
+ * listeners to a field rendered by renderFormattingField().
137
+ */
138
+ export function attachFormattingField(container, id) {
139
+ const wrapper = container.querySelector(`#${id}-wrapper`);
140
+ const textarea = container.querySelector(`#${id}`);
141
+ if (!wrapper || !textarea)
142
+ return;
143
+ // Focus ring on the outer wrapper
144
+ textarea.addEventListener("focus", () => {
145
+ wrapper.style.borderColor = "var(--p-color-border-focus, #2c6ecb)";
146
+ wrapper.style.boxShadow = "0 0 0 1px var(--p-color-border-focus, #2c6ecb)";
147
+ });
148
+ textarea.addEventListener("blur", () => {
149
+ wrapper.style.borderColor = "var(--p-color-border, #c9cccf)";
150
+ wrapper.style.boxShadow = "none";
151
+ });
152
+ // Hover + press states on each toolbar button
153
+ const btns = wrapper.querySelectorAll("button[data-fmt]");
154
+ for (const btn of btns) {
155
+ btn.addEventListener("mouseenter", () => {
156
+ btn.style.background = "var(--p-color-bg-surface-hover, #e4e5e7)";
157
+ btn.style.borderColor = "var(--p-color-border, #8c9196)";
158
+ });
159
+ btn.addEventListener("mouseleave", () => {
160
+ btn.style.background = "var(--p-color-bg-surface, #fff)";
161
+ btn.style.borderColor = "var(--p-color-border-subdued, #c9cccf)";
162
+ });
163
+ btn.addEventListener("mousedown", () => {
164
+ btn.style.background = "var(--p-color-bg-fill-selected, #d2d5d8)";
165
+ btn.style.boxShadow = "inset 0 1px 2px rgba(0,0,0,0.12)";
166
+ });
167
+ btn.addEventListener("mouseup", () => {
168
+ btn.style.boxShadow = "none";
169
+ });
170
+ // Prevent toolbar button clicks from blurring the textarea
171
+ btn.addEventListener("mousedown", (e) => e.preventDefault());
172
+ }
173
+ // Keyboard shortcuts inside the textarea
174
+ textarea.addEventListener("keydown", (e) => {
175
+ if (e.ctrlKey || e.metaKey) {
176
+ if (e.key === "b" || e.key === "B") {
177
+ e.preventDefault();
178
+ applyFormat(textarea, "**");
179
+ }
180
+ else if (e.key === "u" || e.key === "U") {
181
+ e.preventDefault();
182
+ applyFormat(textarea, "__");
183
+ }
184
+ }
185
+ });
186
+ // Button clicks
187
+ wrapper.querySelector(`#${id}-fmt-bold`)?.addEventListener("click", () => {
188
+ applyFormat(textarea, "**");
189
+ });
190
+ wrapper.querySelector(`#${id}-fmt-ul`)?.addEventListener("click", () => {
191
+ applyFormat(textarea, "__");
192
+ });
193
+ }
@@ -1,5 +1,5 @@
1
1
  import { createFileUpload } from "./file-upload";
2
- import { setInnerHtml } from "./dom-utils";
2
+ import { setInnerHtml, escapeHtml, renderFormattingField, attachFormattingField } from "./dom-utils";
3
3
  const FIELD_STYLE = [
4
4
  "width:100%",
5
5
  "padding:10px 12px",
@@ -64,10 +64,13 @@ export function renderNewTicketForm(container, client, categories, emitter) {
64
64
 
65
65
  <div>
66
66
  <label for="sch-body" style="display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--p-color-text, #202223);">Description</label>
67
- <textarea id="sch-body" rows="6" required
68
- style="${FIELD_STYLE}; resize:vertical;"
69
- onfocus="${FOCUS_STYLE}" onblur="${BLUR_STYLE}"
70
- placeholder="Tell us what happened, what you expected, and any steps to reproduce..."></textarea>
67
+ ${renderFormattingField({
68
+ id: "sch-body",
69
+ rows: 6,
70
+ required: true,
71
+ placeholder: "Tell us what happened, what you expected, and any steps to reproduce...",
72
+ extraStyle: "resize:vertical"
73
+ })}
71
74
  </div>
72
75
 
73
76
  ${fileUpload.render()}
@@ -85,6 +88,7 @@ export function renderNewTicketForm(container, client, categories, emitter) {
85
88
  </s-card>
86
89
  `);
87
90
  fileUpload.attachListeners(container);
91
+ attachFormattingField(container, "sch-body");
88
92
  const form = container.querySelector("#sch-new-ticket-form");
89
93
  container.querySelector("#sch-cancel-btn")?.addEventListener("click", () => {
90
94
  emitter.emit("ticket:cancel", undefined);
@@ -141,8 +145,3 @@ export function renderNewTicketForm(container, client, categories, emitter) {
141
145
  }
142
146
  });
143
147
  }
144
- function escapeHtml(text) {
145
- const div = document.createElement("div");
146
- div.textContent = text;
147
- return div.innerHTML;
148
- }
@@ -1,6 +1,6 @@
1
1
  import { renderStatusBadge } from "./status-badge";
2
2
  import { createFileUpload } from "./file-upload";
3
- import { setInnerHtml } from "./dom-utils";
3
+ import { setInnerHtml, escapeHtml, formatBodyText, renderFormattingField, attachFormattingField } from "./dom-utils";
4
4
  function formatTimestamp(timestamp) {
5
5
  const date = new Date(timestamp * 1000);
6
6
  const now = new Date();
@@ -89,11 +89,12 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
89
89
  <div id="sch-reply-section">
90
90
  <s-stack gap="base">
91
91
  <s-text variant="headingSm">Reply</s-text>
92
- <textarea id="sch-reply-body" rows="4"
93
- style="width:100%; padding:12px; border:1px solid var(--p-color-border, #c9cccf); border-radius:8px; font-family:inherit; font-size:14px; line-height:1.5; box-sizing:border-box; resize:vertical; transition:border-color 0.15s ease;"
94
- onfocus="this.style.borderColor='var(--p-color-border-focus, #2c6ecb)'; this.style.outline='none'; this.style.boxShadow='0 0 0 1px var(--p-color-border-focus, #2c6ecb)'"
95
- onblur="this.style.borderColor='var(--p-color-border, #c9cccf)'; this.style.boxShadow='none'"
96
- placeholder="Write your reply..."></textarea>
92
+ ${renderFormattingField({
93
+ id: "sch-reply-body",
94
+ rows: 4,
95
+ placeholder: "Write your reply...",
96
+ extraStyle: "resize:vertical"
97
+ })}
97
98
  <div id="sch-reply-files"></div>
98
99
  <div style="display:flex; justify-content:space-between; align-items:center;">
99
100
  <s-button id="sch-close-ticket-btn">Close Ticket</s-button>
@@ -109,6 +110,8 @@ export async function renderTicketDetail(container, client, ticketId, emitter) {
109
110
  container.querySelector("#sch-back-btn")?.addEventListener("click", () => {
110
111
  emitter.emit("ticket:back", undefined);
111
112
  });
113
+ // Formatting toolbar for reply
114
+ attachFormattingField(container, "sch-reply-body");
112
115
  // Mount file upload for replies
113
116
  let replyFileUpload = null;
114
117
  const replyFilesContainer = container.querySelector("#sch-reply-files");
@@ -226,7 +229,7 @@ function renderMessage(msg) {
226
229
  <s-text variant="bodySm" tone="subdued">${formatTimestamp(msg.createdAt)}</s-text>
227
230
  </div>
228
231
  <div style="background:${bubbleBg}; padding:10px 14px; border-radius:12px; display:inline-block; text-align:left; max-width:100%;">
229
- <s-text variant="bodyMd" style="white-space:pre-wrap; word-break:break-word;">${escapeHtml(msg.bodyPlain)}</s-text>
232
+ <s-text variant="bodyMd" style="white-space:pre-wrap; word-break:break-word;">${formatBodyText(msg.bodyPlain)}</s-text>
230
233
  </div>
231
234
  </div>
232
235
  </div>
@@ -254,8 +257,3 @@ function showError(container, message) {
254
257
  }
255
258
  }
256
259
  }
257
- function escapeHtml(text) {
258
- const div = document.createElement("div");
259
- div.textContent = text;
260
- return div.innerHTML;
261
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catandbox/schrodinger-web-adapter",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",