@catandbox/schrodinger-web-adapter 0.1.19 → 0.1.21
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.
- package/dist/client/dom-utils.d.ts +34 -0
- package/dist/client/dom-utils.js +178 -0
- package/dist/client/new-ticket-form.js +26 -26
- package/dist/client/ticket-detail.js +10 -12
- package/package.json +1 -1
|
@@ -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;
|
package/dist/client/dom-utils.js
CHANGED
|
@@ -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 · 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",
|
|
@@ -16,7 +16,7 @@ const FOCUS_STYLE = "this.style.borderColor='var(--p-color-border-focus, #2c6ecb
|
|
|
16
16
|
const BLUR_STYLE = "this.style.borderColor='var(--p-color-border, #c9cccf)'; this.style.boxShadow='none'";
|
|
17
17
|
export function renderNewTicketForm(container, client, categories, emitter) {
|
|
18
18
|
const categoryOptions = categories
|
|
19
|
-
.map((cat) => `<option value="${cat.id}">${escapeHtml(cat.name)}</option>`)
|
|
19
|
+
.map((cat) => `<s-option value="${cat.id}">${escapeHtml(cat.name)}</s-option>`)
|
|
20
20
|
.join("");
|
|
21
21
|
const fileUpload = createFileUpload();
|
|
22
22
|
setInnerHtml(container, `
|
|
@@ -41,19 +41,6 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
41
41
|
|
|
42
42
|
<form id="sch-new-ticket-form">
|
|
43
43
|
<s-stack gap="base">
|
|
44
|
-
${categories.length > 0
|
|
45
|
-
? `
|
|
46
|
-
<div>
|
|
47
|
-
<label for="sch-category" style="display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--p-color-text, #202223);">Category</label>
|
|
48
|
-
<select id="sch-category" style="${FIELD_STYLE}; cursor:pointer;"
|
|
49
|
-
onfocus="${FOCUS_STYLE}" onblur="${BLUR_STYLE}">
|
|
50
|
-
<option value="">Select a category...</option>
|
|
51
|
-
${categoryOptions}
|
|
52
|
-
</select>
|
|
53
|
-
</div>
|
|
54
|
-
`
|
|
55
|
-
: ""}
|
|
56
|
-
|
|
57
44
|
<div>
|
|
58
45
|
<label for="sch-title" style="display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--p-color-text, #202223);">Subject</label>
|
|
59
46
|
<input id="sch-title" type="text" required
|
|
@@ -62,16 +49,33 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
62
49
|
placeholder="Brief summary of your issue" />
|
|
63
50
|
</div>
|
|
64
51
|
|
|
52
|
+
<div style="display:flex; gap:16px;">
|
|
53
|
+
<div style="flex:1; min-width:0;">
|
|
54
|
+
${fileUpload.render()}
|
|
55
|
+
</div>
|
|
56
|
+
${categories.length > 0
|
|
57
|
+
? `
|
|
58
|
+
<div style="flex:1; min-width:0;">
|
|
59
|
+
<s-select id="sch-category" label="Category">
|
|
60
|
+
<s-option value="">Select a category...</s-option>
|
|
61
|
+
${categoryOptions}
|
|
62
|
+
</s-select>
|
|
63
|
+
</div>
|
|
64
|
+
`
|
|
65
|
+
: ""}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
65
68
|
<div>
|
|
66
69
|
<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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
${renderFormattingField({
|
|
71
|
+
id: "sch-body",
|
|
72
|
+
rows: 6,
|
|
73
|
+
required: true,
|
|
74
|
+
placeholder: "Tell us what happened, what you expected, and any steps to reproduce...",
|
|
75
|
+
extraStyle: "resize:vertical"
|
|
76
|
+
})}
|
|
71
77
|
</div>
|
|
72
78
|
|
|
73
|
-
${fileUpload.render()}
|
|
74
|
-
|
|
75
79
|
<div id="sch-form-error"></div>
|
|
76
80
|
|
|
77
81
|
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:4px;">
|
|
@@ -85,6 +89,7 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
85
89
|
</s-card>
|
|
86
90
|
`);
|
|
87
91
|
fileUpload.attachListeners(container);
|
|
92
|
+
attachFormattingField(container, "sch-body");
|
|
88
93
|
const form = container.querySelector("#sch-new-ticket-form");
|
|
89
94
|
container.querySelector("#sch-cancel-btn")?.addEventListener("click", () => {
|
|
90
95
|
emitter.emit("ticket:cancel", undefined);
|
|
@@ -141,8 +146,3 @@ export function renderNewTicketForm(container, client, categories, emitter) {
|
|
|
141
146
|
}
|
|
142
147
|
});
|
|
143
148
|
}
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;">${
|
|
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
|
-
}
|