@consilioweb/payload-support 0.9.7 → 0.9.9

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.
@@ -99,6 +99,43 @@ const RichTextEditor = react.forwardRef(function RichTextEditor2({
99
99
  }
100
100
  if (fileInputRef.current) fileInputRef.current.value = "";
101
101
  }, [onFileUpload, emitChange]);
102
+ const handleClearFormat = react.useCallback(() => {
103
+ document.execCommand("removeFormat");
104
+ document.execCommand("formatBlock", false, "p");
105
+ editorRef.current?.focus();
106
+ setTimeout(emitChange, 0);
107
+ }, [emitChange]);
108
+ const handleKeyDown = react.useCallback((e) => {
109
+ if (e.key !== "Enter" || e.shiftKey) return;
110
+ const sel = window.getSelection();
111
+ if (!sel || sel.rangeCount === 0) return;
112
+ let node = sel.anchorNode;
113
+ let blockquote = null;
114
+ while (node && node !== editorRef.current) {
115
+ if (node.nodeName === "BLOCKQUOTE") {
116
+ blockquote = node;
117
+ break;
118
+ }
119
+ node = node.parentNode;
120
+ }
121
+ if (!blockquote) return;
122
+ const text = blockquote.innerText || "";
123
+ if (text.replace(/\n+$/, "").length === 0 || text.endsWith("\n\n") || text.endsWith("\n")) {
124
+ e.preventDefault();
125
+ const p = document.createElement("p");
126
+ p.innerHTML = "<br>";
127
+ blockquote.after(p);
128
+ if (blockquote.lastChild && blockquote.lastChild.nodeName === "BR") {
129
+ blockquote.removeChild(blockquote.lastChild);
130
+ }
131
+ const range = document.createRange();
132
+ range.setStart(p, 0);
133
+ range.collapse(true);
134
+ sel.removeAllRanges();
135
+ sel.addRange(range);
136
+ setTimeout(emitChange, 0);
137
+ }
138
+ }, [emitChange]);
102
139
  const handlePaste = react.useCallback(async (e) => {
103
140
  if (!onFileUpload) return;
104
141
  const items = e.clipboardData?.items;
@@ -186,7 +223,15 @@ const RichTextEditor = react.forwardRef(function RichTextEditor2({
186
223
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", style: btn, onMouseDown: (e) => {
187
224
  e.preventDefault();
188
225
  handleImageClick();
189
- }, title: "Ins\xE9rer une image", children: "\u{1F5BC}\uFE0F" })
226
+ }, title: "Ins\xE9rer une image", children: "\u{1F5BC}\uFE0F" }),
227
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: sep }),
228
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", style: { ...btn, fontSize: "13px" }, onMouseDown: (e) => {
229
+ e.preventDefault();
230
+ handleClearFormat();
231
+ }, title: "Effacer la mise en forme", children: [
232
+ "T",
233
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "10px", verticalAlign: "super" }, children: "\xD7" })
234
+ ] })
190
235
  ] }),
191
236
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { position: "relative" }, children: [
192
237
  isEmpty && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "absolute", top: 0, left: 0, right: 0, padding: "14px 16px", color: "#9ca3af", fontSize: "14px", pointerEvents: "none", userSelect: "none" }, children: placeholder }),
@@ -204,6 +249,7 @@ const RichTextEditor = react.forwardRef(function RichTextEditor2({
204
249
  emitChange();
205
250
  },
206
251
  onPaste: handlePaste,
252
+ onKeyDown: handleKeyDown,
207
253
  style: {
208
254
  minHeight: `${minHeight}px`,
209
255
  padding: "14px 16px",
@@ -98,6 +98,43 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
98
98
  }
99
99
  if (fileInputRef.current) fileInputRef.current.value = "";
100
100
  }, [onFileUpload, emitChange]);
101
+ const handleClearFormat = useCallback(() => {
102
+ document.execCommand("removeFormat");
103
+ document.execCommand("formatBlock", false, "p");
104
+ editorRef.current?.focus();
105
+ setTimeout(emitChange, 0);
106
+ }, [emitChange]);
107
+ const handleKeyDown = useCallback((e) => {
108
+ if (e.key !== "Enter" || e.shiftKey) return;
109
+ const sel = window.getSelection();
110
+ if (!sel || sel.rangeCount === 0) return;
111
+ let node = sel.anchorNode;
112
+ let blockquote = null;
113
+ while (node && node !== editorRef.current) {
114
+ if (node.nodeName === "BLOCKQUOTE") {
115
+ blockquote = node;
116
+ break;
117
+ }
118
+ node = node.parentNode;
119
+ }
120
+ if (!blockquote) return;
121
+ const text = blockquote.innerText || "";
122
+ if (text.replace(/\n+$/, "").length === 0 || text.endsWith("\n\n") || text.endsWith("\n")) {
123
+ e.preventDefault();
124
+ const p = document.createElement("p");
125
+ p.innerHTML = "<br>";
126
+ blockquote.after(p);
127
+ if (blockquote.lastChild && blockquote.lastChild.nodeName === "BR") {
128
+ blockquote.removeChild(blockquote.lastChild);
129
+ }
130
+ const range = document.createRange();
131
+ range.setStart(p, 0);
132
+ range.collapse(true);
133
+ sel.removeAllRanges();
134
+ sel.addRange(range);
135
+ setTimeout(emitChange, 0);
136
+ }
137
+ }, [emitChange]);
101
138
  const handlePaste = useCallback(async (e) => {
102
139
  if (!onFileUpload) return;
103
140
  const items = e.clipboardData?.items;
@@ -185,7 +222,15 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
185
222
  /* @__PURE__ */ jsx("button", { type: "button", style: btn, onMouseDown: (e) => {
186
223
  e.preventDefault();
187
224
  handleImageClick();
188
- }, title: "Ins\xE9rer une image", children: "\u{1F5BC}\uFE0F" })
225
+ }, title: "Ins\xE9rer une image", children: "\u{1F5BC}\uFE0F" }),
226
+ /* @__PURE__ */ jsx("span", { style: sep }),
227
+ /* @__PURE__ */ jsxs("button", { type: "button", style: { ...btn, fontSize: "13px" }, onMouseDown: (e) => {
228
+ e.preventDefault();
229
+ handleClearFormat();
230
+ }, title: "Effacer la mise en forme", children: [
231
+ "T",
232
+ /* @__PURE__ */ jsx("span", { style: { fontSize: "10px", verticalAlign: "super" }, children: "\xD7" })
233
+ ] })
189
234
  ] }),
190
235
  /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
191
236
  isEmpty && /* @__PURE__ */ jsx("div", { style: { position: "absolute", top: 0, left: 0, right: 0, padding: "14px 16px", color: "#9ca3af", fontSize: "14px", pointerEvents: "none", userSelect: "none" }, children: placeholder }),
@@ -203,6 +248,7 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
203
248
  emitChange();
204
249
  },
205
250
  onPaste: handlePaste,
251
+ onKeyDown: handleKeyDown,
206
252
  style: {
207
253
  minHeight: `${minHeight}px`,
208
254
  padding: "14px 16px",
@@ -139,6 +139,9 @@ const TicketDetailClient = () => {
139
139
  const [isInternal, setIsInternal] = React.useState(false);
140
140
  const [notifyClient, setNotifyClient] = React.useState(true);
141
141
  const [sendAsClient, setSendAsClient] = React.useState(false);
142
+ const [editingMsgId, setEditingMsgId] = React.useState(null);
143
+ const [editingBody, setEditingBody] = React.useState("");
144
+ const [editSaving, setEditSaving] = React.useState(false);
142
145
  const [sending, setSending] = React.useState(false);
143
146
  const [showMenu, setShowMenu] = React.useState(false);
144
147
  const [clientTyping, setClientTyping] = React.useState(false);
@@ -477,6 +480,34 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
477
480
  setUndoToast(null);
478
481
  }
479
482
  };
483
+ const startEditMessage = (msg) => {
484
+ setEditingMsgId(msg.id);
485
+ setEditingBody(msg.body || "");
486
+ };
487
+ const cancelEditMessage = () => {
488
+ setEditingMsgId(null);
489
+ setEditingBody("");
490
+ };
491
+ const saveEditMessage = async () => {
492
+ if (editingMsgId === null) return;
493
+ setEditSaving(true);
494
+ try {
495
+ const res = await fetch(`/api/ticket-messages/${editingMsgId}`, {
496
+ method: "PATCH",
497
+ headers: { "Content-Type": "application/json" },
498
+ credentials: "include",
499
+ body: JSON.stringify({ body: editingBody, bodyHtml: null, skipNotification: true })
500
+ });
501
+ if (res.ok) {
502
+ setEditingMsgId(null);
503
+ setEditingBody("");
504
+ fetchAll();
505
+ }
506
+ } catch {
507
+ } finally {
508
+ setEditSaving(false);
509
+ }
510
+ };
480
511
  const handleSplitConfirm = async () => {
481
512
  if (!splitModal || !splitSubject.trim()) return;
482
513
  try {
@@ -686,7 +717,21 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
686
717
  return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: "#94a3b8" }, children: "\u2713" });
687
718
  })() })
688
719
  ] }),
689
- msg.deletedAt ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: s__default.default.messageBody, style: { color: "#94a3b8", fontStyle: "italic" }, children: t("detail.messageDeleted") }) : msg.bodyHtml && CodeBlock.hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, "")) ? /* @__PURE__ */ jsxRuntime.jsx(CodeBlock.CodeBlockRendererHtml, { html: msg.bodyHtml }) : msg.bodyHtml ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: `${s__default.default.messageBody} ${s__default.default.rteDisplay}`, dangerouslySetInnerHTML: { __html: msg.bodyHtml } }) : CodeBlock.hasCodeBlocks(msg.body) ? /* @__PURE__ */ jsxRuntime.jsx(CodeBlock.MessageWithCodeBlocks, { text: msg.body, style: { fontSize: "13px", lineHeight: 1.5 } }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: s__default.default.messageBody, children: msg.body }),
720
+ editingMsgId === msg.id ? /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
721
+ /* @__PURE__ */ jsxRuntime.jsx(
722
+ "textarea",
723
+ {
724
+ value: editingBody,
725
+ onChange: (e) => setEditingBody(e.target.value),
726
+ style: { width: "100%", minHeight: 120, padding: 10, fontSize: 13, lineHeight: 1.5, fontFamily: "inherit", border: "1px solid var(--theme-elevation-200)", borderRadius: 6, background: "var(--theme-elevation-0)", color: "var(--theme-text)" },
727
+ autoFocus: true
728
+ }
729
+ ),
730
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 6, justifyContent: "flex-end" }, children: [
731
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: cancelEditMessage, disabled: editSaving, style: { padding: "5px 12px", fontSize: 12, borderRadius: 5, border: "1px solid var(--theme-elevation-200)", background: "var(--theme-elevation-0)", color: "var(--theme-text)", cursor: "pointer" }, children: "Annuler" }),
732
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: saveEditMessage, disabled: editSaving || !editingBody.trim(), style: { padding: "5px 12px", fontSize: 12, fontWeight: 600, borderRadius: 5, border: "none", background: "#2563eb", color: "#fff", cursor: editSaving ? "wait" : "pointer", opacity: editSaving ? 0.6 : 1 }, children: editSaving ? "Sauvegarde\u2026" : "Enregistrer" })
733
+ ] })
734
+ ] }) : msg.deletedAt ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: s__default.default.messageBody, style: { color: "#94a3b8", fontStyle: "italic" }, children: t("detail.messageDeleted") }) : msg.bodyHtml && CodeBlock.hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, "")) ? /* @__PURE__ */ jsxRuntime.jsx(CodeBlock.CodeBlockRendererHtml, { html: msg.bodyHtml }) : msg.bodyHtml ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: `${s__default.default.messageBody} ${s__default.default.rteDisplay}`, dangerouslySetInnerHTML: { __html: msg.bodyHtml } }) : CodeBlock.hasCodeBlocks(msg.body) ? /* @__PURE__ */ jsxRuntime.jsx(CodeBlock.MessageWithCodeBlocks, { text: msg.body, style: { fontSize: "13px", lineHeight: 1.5 } }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: s__default.default.messageBody, children: msg.body }),
690
735
  Array.isArray(msg.attachments) && msg.attachments.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: s__default.default.attachments, children: msg.attachments.map((att, i) => {
691
736
  const file = typeof att.file === "object" ? att.file : null;
692
737
  if (!file) return null;
@@ -696,7 +741,8 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
696
741
  ] }, i);
697
742
  }) })
698
743
  ] }),
699
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: s__default.default.messageActions, children: [
744
+ editingMsgId !== msg.id && !msg.deletedAt && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: s__default.default.messageActions, children: [
745
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: s__default.default.actionIcon, title: "\xC9diter", "aria-label": "\xC9diter le message", onClick: () => startEditMessage(msg), style: { fontSize: 11, width: "auto", padding: "4px 8px" }, children: "\xC9diter" }),
700
746
  /* @__PURE__ */ jsxRuntime.jsx("button", { className: `${s__default.default.actionIcon} ${s__default.default.danger}`, title: t("actions.deleteMessage"), "aria-label": t("actions.deleteMessage"), onClick: () => handleDeleteMessage(msg.id), style: { fontSize: 11, width: "auto", padding: "4px 8px" }, children: t("actions.deleteMessage") }),
701
747
  features.splitTicket && !msg.isInternal && /* @__PURE__ */ jsxRuntime.jsx("button", { className: s__default.default.actionIcon, title: t("actions.extractMessage"), "aria-label": t("actions.extractToNewTicket"), onClick: () => {
702
748
  setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) });
@@ -132,6 +132,9 @@ const TicketDetailClient = () => {
132
132
  const [isInternal, setIsInternal] = useState(false);
133
133
  const [notifyClient, setNotifyClient] = useState(true);
134
134
  const [sendAsClient, setSendAsClient] = useState(false);
135
+ const [editingMsgId, setEditingMsgId] = useState(null);
136
+ const [editingBody, setEditingBody] = useState("");
137
+ const [editSaving, setEditSaving] = useState(false);
135
138
  const [sending, setSending] = useState(false);
136
139
  const [showMenu, setShowMenu] = useState(false);
137
140
  const [clientTyping, setClientTyping] = useState(false);
@@ -470,6 +473,34 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
470
473
  setUndoToast(null);
471
474
  }
472
475
  };
476
+ const startEditMessage = (msg) => {
477
+ setEditingMsgId(msg.id);
478
+ setEditingBody(msg.body || "");
479
+ };
480
+ const cancelEditMessage = () => {
481
+ setEditingMsgId(null);
482
+ setEditingBody("");
483
+ };
484
+ const saveEditMessage = async () => {
485
+ if (editingMsgId === null) return;
486
+ setEditSaving(true);
487
+ try {
488
+ const res = await fetch(`/api/ticket-messages/${editingMsgId}`, {
489
+ method: "PATCH",
490
+ headers: { "Content-Type": "application/json" },
491
+ credentials: "include",
492
+ body: JSON.stringify({ body: editingBody, bodyHtml: null, skipNotification: true })
493
+ });
494
+ if (res.ok) {
495
+ setEditingMsgId(null);
496
+ setEditingBody("");
497
+ fetchAll();
498
+ }
499
+ } catch {
500
+ } finally {
501
+ setEditSaving(false);
502
+ }
503
+ };
473
504
  const handleSplitConfirm = async () => {
474
505
  if (!splitModal || !splitSubject.trim()) return;
475
506
  try {
@@ -679,7 +710,21 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
679
710
  return /* @__PURE__ */ jsx("span", { style: { color: "#94a3b8" }, children: "\u2713" });
680
711
  })() })
681
712
  ] }),
682
- msg.deletedAt ? /* @__PURE__ */ jsx("div", { className: s.messageBody, style: { color: "#94a3b8", fontStyle: "italic" }, children: t("detail.messageDeleted") }) : msg.bodyHtml && hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, "")) ? /* @__PURE__ */ jsx(CodeBlockRendererHtml, { html: msg.bodyHtml }) : msg.bodyHtml ? /* @__PURE__ */ jsx("div", { className: `${s.messageBody} ${s.rteDisplay}`, dangerouslySetInnerHTML: { __html: msg.bodyHtml } }) : hasCodeBlocks(msg.body) ? /* @__PURE__ */ jsx(MessageWithCodeBlocks, { text: msg.body, style: { fontSize: "13px", lineHeight: 1.5 } }) : /* @__PURE__ */ jsx("div", { className: s.messageBody, children: msg.body }),
713
+ editingMsgId === msg.id ? /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
714
+ /* @__PURE__ */ jsx(
715
+ "textarea",
716
+ {
717
+ value: editingBody,
718
+ onChange: (e) => setEditingBody(e.target.value),
719
+ style: { width: "100%", minHeight: 120, padding: 10, fontSize: 13, lineHeight: 1.5, fontFamily: "inherit", border: "1px solid var(--theme-elevation-200)", borderRadius: 6, background: "var(--theme-elevation-0)", color: "var(--theme-text)" },
720
+ autoFocus: true
721
+ }
722
+ ),
723
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, justifyContent: "flex-end" }, children: [
724
+ /* @__PURE__ */ jsx("button", { onClick: cancelEditMessage, disabled: editSaving, style: { padding: "5px 12px", fontSize: 12, borderRadius: 5, border: "1px solid var(--theme-elevation-200)", background: "var(--theme-elevation-0)", color: "var(--theme-text)", cursor: "pointer" }, children: "Annuler" }),
725
+ /* @__PURE__ */ jsx("button", { onClick: saveEditMessage, disabled: editSaving || !editingBody.trim(), style: { padding: "5px 12px", fontSize: 12, fontWeight: 600, borderRadius: 5, border: "none", background: "#2563eb", color: "#fff", cursor: editSaving ? "wait" : "pointer", opacity: editSaving ? 0.6 : 1 }, children: editSaving ? "Sauvegarde\u2026" : "Enregistrer" })
726
+ ] })
727
+ ] }) : msg.deletedAt ? /* @__PURE__ */ jsx("div", { className: s.messageBody, style: { color: "#94a3b8", fontStyle: "italic" }, children: t("detail.messageDeleted") }) : msg.bodyHtml && hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, "")) ? /* @__PURE__ */ jsx(CodeBlockRendererHtml, { html: msg.bodyHtml }) : msg.bodyHtml ? /* @__PURE__ */ jsx("div", { className: `${s.messageBody} ${s.rteDisplay}`, dangerouslySetInnerHTML: { __html: msg.bodyHtml } }) : hasCodeBlocks(msg.body) ? /* @__PURE__ */ jsx(MessageWithCodeBlocks, { text: msg.body, style: { fontSize: "13px", lineHeight: 1.5 } }) : /* @__PURE__ */ jsx("div", { className: s.messageBody, children: msg.body }),
683
728
  Array.isArray(msg.attachments) && msg.attachments.length > 0 && /* @__PURE__ */ jsx("div", { className: s.attachments, children: msg.attachments.map((att, i) => {
684
729
  const file = typeof att.file === "object" ? att.file : null;
685
730
  if (!file) return null;
@@ -689,7 +734,8 @@ ${uploadedLinks.join("\n")}` : replyBody.trim() || "[Contenu enrichi]";
689
734
  ] }, i);
690
735
  }) })
691
736
  ] }),
692
- /* @__PURE__ */ jsxs("div", { className: s.messageActions, children: [
737
+ editingMsgId !== msg.id && !msg.deletedAt && /* @__PURE__ */ jsxs("div", { className: s.messageActions, children: [
738
+ /* @__PURE__ */ jsx("button", { className: s.actionIcon, title: "\xC9diter", "aria-label": "\xC9diter le message", onClick: () => startEditMessage(msg), style: { fontSize: 11, width: "auto", padding: "4px 8px" }, children: "\xC9diter" }),
693
739
  /* @__PURE__ */ jsx("button", { className: `${s.actionIcon} ${s.danger}`, title: t("actions.deleteMessage"), "aria-label": t("actions.deleteMessage"), onClick: () => handleDeleteMessage(msg.id), style: { fontSize: 11, width: "auto", padding: "4px 8px" }, children: t("actions.deleteMessage") }),
694
740
  features.splitTicket && !msg.isInternal && /* @__PURE__ */ jsx("button", { className: s.actionIcon, title: t("actions.extractMessage"), "aria-label": t("actions.extractToNewTicket"), onClick: () => {
695
741
  setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -129,6 +129,49 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
129
129
  if (fileInputRef.current) fileInputRef.current.value = ''
130
130
  }, [onFileUpload, emitChange])
131
131
 
132
+ const handleClearFormat = useCallback(() => {
133
+ document.execCommand('removeFormat')
134
+ document.execCommand('formatBlock', false, 'p')
135
+ editorRef.current?.focus()
136
+ setTimeout(emitChange, 0)
137
+ }, [emitChange])
138
+
139
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
140
+ if (e.key !== 'Enter' || e.shiftKey) return
141
+ const sel = window.getSelection()
142
+ if (!sel || sel.rangeCount === 0) return
143
+ // Find closest blockquote ancestor of the caret
144
+ let node: Node | null = sel.anchorNode
145
+ let blockquote: HTMLElement | null = null
146
+ while (node && node !== editorRef.current) {
147
+ if ((node as HTMLElement).nodeName === 'BLOCKQUOTE') {
148
+ blockquote = node as HTMLElement
149
+ break
150
+ }
151
+ node = node.parentNode
152
+ }
153
+ if (!blockquote) return
154
+ // Exit on Enter when the last line of the blockquote is empty
155
+ const text = blockquote.innerText || ''
156
+ if (text.replace(/\n+$/, '').length === 0 || text.endsWith('\n\n') || text.endsWith('\n')) {
157
+ e.preventDefault()
158
+ // Insert a paragraph after the blockquote and move caret into it
159
+ const p = document.createElement('p')
160
+ p.innerHTML = '<br>'
161
+ blockquote.after(p)
162
+ // Clean trailing empty <br> inside blockquote
163
+ if (blockquote.lastChild && (blockquote.lastChild as HTMLElement).nodeName === 'BR') {
164
+ blockquote.removeChild(blockquote.lastChild)
165
+ }
166
+ const range = document.createRange()
167
+ range.setStart(p, 0)
168
+ range.collapse(true)
169
+ sel.removeAllRanges()
170
+ sel.addRange(range)
171
+ setTimeout(emitChange, 0)
172
+ }
173
+ }, [emitChange])
174
+
132
175
  const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
133
176
  if (!onFileUpload) return
134
177
  const items = e.clipboardData?.items
@@ -215,6 +258,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
215
258
  <button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); handleImageClick() }} title="Insérer une image">
216
259
  &#128444;&#65039;
217
260
  </button>
261
+ <span style={sep} />
262
+ <button type="button" style={{ ...btn, fontSize: '13px' }} onMouseDown={(e) => { e.preventDefault(); handleClearFormat() }} title="Effacer la mise en forme">
263
+ T<span style={{ fontSize: '10px', verticalAlign: 'super' }}>&times;</span>
264
+ </button>
218
265
  </div>
219
266
 
220
267
  {/* Editor area */}
@@ -233,6 +280,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
233
280
  onFocus={() => setFocused(true)}
234
281
  onBlur={() => { setFocused(false); emitChange() }}
235
282
  onPaste={handlePaste}
283
+ onKeyDown={handleKeyDown}
236
284
  style={{
237
285
  minHeight: `${minHeight}px`,
238
286
  padding: '14px 16px',
@@ -133,6 +133,10 @@ export const TicketDetailClient: React.FC = () => {
133
133
  const [isInternal, setIsInternal] = useState(false)
134
134
  const [notifyClient, setNotifyClient] = useState(true)
135
135
  const [sendAsClient, setSendAsClient] = useState(false)
136
+ // Inline message edit
137
+ const [editingMsgId, setEditingMsgId] = useState<string | number | null>(null)
138
+ const [editingBody, setEditingBody] = useState('')
139
+ const [editSaving, setEditSaving] = useState(false)
136
140
  const [sending, setSending] = useState(false)
137
141
 
138
142
  const [showMenu, setShowMenu] = useState(false)
@@ -441,6 +445,37 @@ const [clientTyping, setClientTyping] = useState(false)
441
445
  }
442
446
  }
443
447
 
448
+ // Inline edit message (body only, clears bodyHtml so the edit shows as plain text)
449
+ const startEditMessage = (msg: Message) => {
450
+ setEditingMsgId(msg.id)
451
+ setEditingBody(msg.body || '')
452
+ }
453
+
454
+ const cancelEditMessage = () => {
455
+ setEditingMsgId(null)
456
+ setEditingBody('')
457
+ }
458
+
459
+ const saveEditMessage = async () => {
460
+ if (editingMsgId === null) return
461
+ setEditSaving(true)
462
+ try {
463
+ const res = await fetch(`/api/ticket-messages/${editingMsgId}`, {
464
+ method: 'PATCH',
465
+ headers: { 'Content-Type': 'application/json' },
466
+ credentials: 'include',
467
+ body: JSON.stringify({ body: editingBody, bodyHtml: null, skipNotification: true }),
468
+ })
469
+ if (res.ok) {
470
+ setEditingMsgId(null)
471
+ setEditingBody('')
472
+ fetchAll()
473
+ }
474
+ } catch { /* silent */ } finally {
475
+ setEditSaving(false)
476
+ }
477
+ }
478
+
444
479
  // #2 — Split ticket with modal
445
480
  const handleSplitConfirm = async () => {
446
481
  if (!splitModal || !splitSubject.trim()) return
@@ -617,7 +652,20 @@ const [clientTyping, setClientTyping] = useState(false)
617
652
  })()}
618
653
  </span>
619
654
  </div>
620
- {(msg as unknown as { deletedAt?: string }).deletedAt ? (
655
+ {editingMsgId === msg.id ? (
656
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
657
+ <textarea
658
+ value={editingBody}
659
+ onChange={(e) => setEditingBody(e.target.value)}
660
+ style={{ width: '100%', minHeight: 120, padding: 10, fontSize: 13, lineHeight: 1.5, fontFamily: 'inherit', border: '1px solid var(--theme-elevation-200)', borderRadius: 6, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' }}
661
+ autoFocus
662
+ />
663
+ <div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
664
+ <button onClick={cancelEditMessage} disabled={editSaving} style={{ padding: '5px 12px', fontSize: 12, borderRadius: 5, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', cursor: 'pointer' }}>Annuler</button>
665
+ <button onClick={saveEditMessage} disabled={editSaving || !editingBody.trim()} style={{ padding: '5px 12px', fontSize: 12, fontWeight: 600, borderRadius: 5, border: 'none', background: '#2563eb', color: '#fff', cursor: editSaving ? 'wait' : 'pointer', opacity: editSaving ? 0.6 : 1 }}>{editSaving ? 'Sauvegarde…' : 'Enregistrer'}</button>
666
+ </div>
667
+ </div>
668
+ ) : (msg as unknown as { deletedAt?: string }).deletedAt ? (
621
669
  <div className={s.messageBody} style={{ color: '#94a3b8', fontStyle: 'italic' }}>{t('detail.messageDeleted')}</div>
622
670
  ) : msg.bodyHtml && hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, '')) ? (
623
671
  <CodeBlockRendererHtml html={msg.bodyHtml} />
@@ -641,13 +689,16 @@ const [clientTyping, setClientTyping] = useState(false)
641
689
  )}
642
690
  </div>
643
691
  {/* Hover actions — icon buttons with aria-labels (#7) */}
644
- <div className={s.messageActions}>
645
- {/* #2 — Undo toast instead of confirm() */}
646
- <button className={`${s.actionIcon} ${s.danger}`} title={t('actions.deleteMessage')} aria-label={t('actions.deleteMessage')} onClick={() => handleDeleteMessage(msg.id)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.deleteMessage')}</button>
647
- {/* #2 — Split modal instead of prompt() */}
648
- {features.splitTicket && !msg.isInternal && <button className={s.actionIcon} title={t('actions.extractMessage')} aria-label={t('actions.extractToNewTicket')} onClick={() => { setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) }); setSplitSubject(`Split: ${ticket.subject}`) }} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.extractMessage')}</button>}
649
- {isAdmin && !msg.isInternal && <button className={s.actionIcon} title={t('actions.resendEmail')} aria-label={t('actions.resendEmail')} onClick={() => fetch('/api/support/resend-notification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ messageId: msg.id }) })} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.resendEmail')}</button>}
650
- </div>
692
+ {editingMsgId !== msg.id && !(msg as unknown as { deletedAt?: string }).deletedAt && (
693
+ <div className={s.messageActions}>
694
+ <button className={s.actionIcon} title="Éditer" aria-label="Éditer le message" onClick={() => startEditMessage(msg)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>Éditer</button>
695
+ {/* #2 — Undo toast instead of confirm() */}
696
+ <button className={`${s.actionIcon} ${s.danger}`} title={t('actions.deleteMessage')} aria-label={t('actions.deleteMessage')} onClick={() => handleDeleteMessage(msg.id)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.deleteMessage')}</button>
697
+ {/* #2 Split modal instead of prompt() */}
698
+ {features.splitTicket && !msg.isInternal && <button className={s.actionIcon} title={t('actions.extractMessage')} aria-label={t('actions.extractToNewTicket')} onClick={() => { setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) }); setSplitSubject(`Split: ${ticket.subject}`) }} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.extractMessage')}</button>}
699
+ {isAdmin && !msg.isInternal && <button className={s.actionIcon} title={t('actions.resendEmail')} aria-label={t('actions.resendEmail')} onClick={() => fetch('/api/support/resend-notification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ messageId: msg.id }) })} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.resendEmail')}</button>}
700
+ </div>
701
+ )}
651
702
  </div>
652
703
  </React.Fragment>
653
704
  )