diff --git a/packages/comment-widget/src/base-comment-item.ts b/packages/comment-widget/src/base-comment-item.ts index 81850de..80ac846 100644 --- a/packages/comment-widget/src/base-comment-item.ts +++ b/packages/comment-widget/src/base-comment-item.ts @@ -43,6 +43,9 @@ export class BaseCommentItem extends LitElement { @property({ type: Boolean }) private: boolean | undefined; + @property({ type: Boolean }) + editing = false; + @consume({ context: configMapDataContext }) @state() configMapData: ConfigMapData | undefined; @@ -106,7 +109,12 @@ export class BaseCommentItem extends LitElement { ${when(!this.approved, () => html`
${msg('Reviewing')}
`)} -
+
${when( + this.editing, + () => html``, + () => + html`` + )}
@@ -125,6 +133,11 @@ export class BaseCommentItem extends LitElement { contain-intrinsic-size: auto 4em; } + /* Paint containment would clip the focus ring of the slotted editor. */ + .item-content-editing { + content-visibility: visible; + } + .animate-breath { animation: breath 1s ease-in-out infinite; } diff --git a/packages/comment-widget/src/comment-edit-form.ts b/packages/comment-widget/src/comment-edit-form.ts new file mode 100644 index 0000000..2829c65 --- /dev/null +++ b/packages/comment-widget/src/comment-edit-form.ts @@ -0,0 +1,323 @@ +import type { CommentVo, ReplyVo } from '@halo-dev/api-client'; +import { consume } from '@lit/context'; +import { msg } from '@lit/localize'; +import { css, html, LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef, type Ref, ref } from 'lit/directives/ref.js'; +import { when } from 'lit/directives/when.js'; +import { FetchError } from 'ofetch'; +import type { CommentEditor } from './comment-editor'; +import { baseUrlContext, configMapDataContext, toastContext } from './context'; +import type { ToastManager } from './lit-toast'; +import baseStyles from './styles/base'; +import type { ConfigMapData } from './types'; +import { + fetchCommentContent, + updateCommentContent, +} from './utils/comment-management'; +import './comment-editor'; +import './loading-block'; +import './icons/icon-loading'; +import { cleanHtml, toEditorContent } from './utils/html'; + +export class CommentEditForm extends LitElement { + @consume({ context: baseUrlContext }) + @state() + baseUrl = ''; + + @consume({ context: configMapDataContext }) + @state() + configMapData: ConfigMapData | undefined; + + @consume({ context: toastContext, subscribe: true }) + @state() + toastManager: ToastManager | undefined; + + @property({ attribute: false }) + target: CommentVo | ReplyVo | undefined; + + @property() + resource: 'comments' | 'replies' = 'comments'; + + @state() + private loading = true; + + @state() + private deleted = false; + + @state() + private loadFailed = false; + + @state() + private version: number | undefined; + + @state() + private content = ''; + + @state() + private saving = false; + + @state() + private errorMessage = ''; + + private initialRaw = ''; + + private editorContent = ''; + + private baseline = ''; + + private editorRef: Ref = createRef(); + + override connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('keydown', this.onKeydown); + void this.loadLatest(); + } + + override disconnectedCallback(): void { + this.removeEventListener('keydown', this.onKeydown); + super.disconnectedCallback(); + } + + private onKeydown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void this.handleSave(); + } + }; + + private async loadLatest() { + if (!this.target) { + this.loading = false; + this.loadFailed = true; + return; + } + this.loading = true; + this.loadFailed = false; + try { + const latest = await fetchCommentContent( + this.baseUrl, + this.resource, + this.target.metadata.name + ); + if (latest.metadata.deletionTimestamp) { + this.deleted = true; + return; + } + this.version = latest.metadata.version ?? undefined; + this.initialRaw = latest.spec.raw || ''; + this.editorContent = toEditorContent(this.initialRaw); + this.content = this.initialRaw; + } catch { + this.loadFailed = true; + } finally { + this.loading = false; + } + if (!this.loadFailed && !this.deleted) { + void this.focusEditor(); + } + } + + private async focusEditor() { + await this.updateComplete; + const editor = this.editorRef.value; + if (!editor) { + return; + } + // The editor is created asynchronously after its dynamic imports resolve. + for (let i = 0; i < 100 && !editor.editor && this.isConnected; i++) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (this.isConnected && editor.editor) { + // The editor normalizes the initial content (e.g. wrapping plain text + // in a paragraph), so the serialized HTML differs from the raw source. + // Record it as the baseline for the dirty check to avoid treating an + // unchanged or undone document as modified. + this.baseline = cleanHtml(editor.editor.getHTML()); + if (this.content === this.initialRaw) { + this.content = this.baseline; + } + editor.setFocus(); + } + } + + private onEditorUpdate( + event: CustomEvent<{ content: string; characterCount: number }> + ) { + this.content = event.detail.content; + this.errorMessage = ''; + } + + private get hasContent() { + const body = new DOMParser().parseFromString( + this.content, + 'text/html' + ).body; + return ( + !!body.textContent?.replaceAll('\u00a0', ' ').trim() || + Array.from(body.querySelectorAll('img[src]')).some((image) => + image.getAttribute('src')?.trim() + ) + ); + } + + private get dirty() { + return this.baseline !== '' && this.content !== this.baseline; + } + + private get canSave() { + return ( + !this.saving && + this.version !== undefined && + this.dirty && + this.hasContent + ); + } + + private async handleSave() { + if (!this.canSave || !this.target) { + return; + } + this.saving = true; + this.errorMessage = ''; + try { + await updateCommentContent( + this.baseUrl, + this.resource, + this.target.metadata.name, + { + raw: this.content, + content: this.content, + version: this.version as number, + } + ); + this.toastManager?.success(msg('Comment updated successfully')); + this.dispatchEvent( + new CustomEvent('comment-managed', { + bubbles: true, + composed: true, + detail: { + action: 'edit', + restoreFocus: false, + commentName: + this.resource === 'replies' + ? (this.target as ReplyVo).spec.commentName + : undefined, + }, + }) + ); + this.dispatchEvent( + new CustomEvent('edit-close', { bubbles: true, composed: true }) + ); + } catch (error) { + const status = + error instanceof FetchError ? error.response?.status : undefined; + this.errorMessage = + status === 409 + ? msg( + 'This comment or reply has changed. Copy your draft, then reopen the editor to load the latest version.' + ) + : status === 404 + ? msg( + 'Could not save. The comment may have been deleted, or the current Halo version does not support editing. Your draft has been kept.' + ) + : msg('Could not save. Your draft has been kept.'); + this.saving = false; + } + } + + private handleCancel() { + if (this.dirty && !window.confirm(msg('Discard your changes?'))) { + return; + } + this.dispatchEvent( + new CustomEvent('edit-close', { bubbles: true, composed: true }) + ); + } + + override render() { + if (this.loading) { + return html` +
+ ${this.renderCancelButton()} +
`; + } + if (this.deleted) { + return html` + ${this.renderCancelButton()}`; + } + if (this.loadFailed) { + return html` +
+ ${this.renderCancelButton()} + +
`; + } + return html` + + ${when( + this.errorMessage, + () => + html`` + )} +
+ ${this.renderCancelButton()} + +
+ `; + } + + private renderCancelButton() { + return html``; + } + + static override styles = [ + ...baseStyles, + css` + :host { + display: block; + } + @unocss-placeholder; + `, + ]; +} + +customElements.get('comment-edit-form') || + customElements.define('comment-edit-form', CommentEditForm); + +declare global { + interface HTMLElementTagNameMap { + 'comment-edit-form': CommentEditForm; + } +} diff --git a/packages/comment-widget/src/comment-item.ts b/packages/comment-widget/src/comment-item.ts index 58476d3..2c37d89 100644 --- a/packages/comment-widget/src/comment-item.ts +++ b/packages/comment-widget/src/comment-item.ts @@ -7,6 +7,7 @@ import './comment-replies'; import './user-avatar'; import './base-comment-item'; import './comment-management'; +import './comment-edit-form'; import { consume } from '@lit/context'; import { msg } from '@lit/localize'; import { createRef, type Ref, ref } from 'lit/directives/ref.js'; @@ -36,6 +37,9 @@ export class CommentItem extends LitElement { @state() showReplyForm = false; + @state() + showEditForm = false; + @state() upvoted = false; @@ -143,6 +147,15 @@ export class CommentItem extends LitElement { this.showReplyForm = false; } + private async handleCloseEditForm() { + this.showEditForm = false; + await this.updateComplete; + this.renderRoot + .querySelector('comment-management') + ?.shadowRoot?.querySelector('summary') + ?.focus({ preventScroll: true }); + } + handleToggleReplyForm() { if (this.showReplyForm) { this.closeReplyForm(); @@ -169,6 +182,7 @@ export class CommentItem extends LitElement { .userWebsite=${this.comment?.spec.owner.annotations?.website} .ua=${this.comment?.spec.userAgent} .private=${this.comment?.spec.hidden} + .editing=${this.showEditForm} > ${ this.resource === 'comments' ? html` diff --git a/packages/comment-widget/src/generated/locales/es.ts b/packages/comment-widget/src/generated/locales/es.ts index 48541ba..1b8ee4e 100644 --- a/packages/comment-widget/src/generated/locales/es.ts +++ b/packages/comment-widget/src/generated/locales/es.ts @@ -20,20 +20,28 @@ 's0fbf6dc6a1966408': `Siguiente`, 's107ccef507b51f2c': `Completa la verificación`, 's15e33945d13c5176': `Se está confirmando tu envío anterior. Inténtalo más tarde; se conservarán tus imágenes.`, +'s176fe95698857ad9': `El comentario o la respuesta ha cambiado. Copia tu borrador y vuelve a abrir el editor para cargar la última versión.`, 's1c6fefb092506753': `Error al cargar la lista de comentarios, por favor intente más tarde`, 's1d468f888124a55e': `Tu borrador ha cambiado. Vuelve a abrirlo antes de reintentar.`, 's1e3e30a26025484c': `Error al cargar la lista de respuestas, por favor intente más tarde`, +'s2254107bb49631da': `Este comentario o respuesta ha sido eliminado.`, +'s22ae36191f9a1a8d': `No se pudo cargar el contenido más reciente. Inténtalo de nuevo más tarde.`, 's2406b89e991a4524': `Actualizar código de verificación`, 's26e4d65f2801ac9c': `Por favor, ingrese el contenido`, 's299b10f3a58a09fd': `Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.`, +'s2c8189544e3ea679': `Reintentar`, 's2ca7699f7c5a4996': `Administrar`, +'s2ceb11be2290bb1b': `Cancelar`, +'s2df0abe59a97747a': `¿Descartar los cambios?`, 's2e192b19ed15fcf6': `Página`, +'s33f85f24c0f5f008': `Guardar`, 's34239948d1de01f1': `Verificación no disponible. Haz clic para reintentar.`, 's35c3125c7750681e': `Cita`, 's3643189d1abbb7f4': `Código`, 's3fb33d17bad61aa9': `Comentario enviado con éxito, pendiente de revisión`, 's44851a8adf059eef': `Fijado`, 's4c0e15f9073382e6': `Error al obtener el código de verificación`, +'s4d76c6944ffb0540': `No se pudo guardar. Es posible que el comentario se haya eliminado o que la versión actual de Halo no admita la edición. Tu borrador se ha conservado.`, 's5184a3f3e2f7b603': `Actualmente anónimo. Después de seleccionar la opción privada, el comentario solo será visible para el administrador del sitio.`, 's523eb9043213ff0d': `Itálica`, 's5787e20cab57b383': `Error desconocido`, @@ -41,6 +49,8 @@ 's59b95f09e3b42060': `La verificación ha fallado. Vuelve a verificar e intenta enviar de nuevo.`, 's5fc35a09a85fe63e': `Cerrar imagen`, 's63ce0636351bf780': `Los resultados de la subida están incompletos. Inténtalo de nuevo.`, +'s63efea23113001ee': `No se pudo guardar. Se ha conservado tu borrador.`, +'s64ef2a6c2dd1d3d1': `Editar`, 's67749057edb2586b': `Cerrar sesión`, 's6cb61eeccda272d5': `Bloque de código`, 's6f23997fffbecc6a': `Cancelar aprobación`, @@ -62,6 +72,7 @@ 'sa10fbe3fcd6ae148': `Agregar respuesta`, 'sa3443b99ecf186ea': `Mostrar`, 'sa82fbb7f361c6f44': `Mostrar respuestas`, +'sa862e76e95967816': `Comentario actualizado correctamente`, 'sa8dddacbaa66f8e0': `Por favor, inicie sesión primero`, 'sb206d700d26b14ff': `Subrayado`, 'sb3d4f79d9d8b71e5': `Enviar comentario`, diff --git a/packages/comment-widget/src/generated/locales/zh-CN.ts b/packages/comment-widget/src/generated/locales/zh-CN.ts index 0024d5b..82a41ef 100644 --- a/packages/comment-widget/src/generated/locales/zh-CN.ts +++ b/packages/comment-widget/src/generated/locales/zh-CN.ts @@ -20,20 +20,28 @@ 's0fbf6dc6a1966408': `下一页`, 's107ccef507b51f2c': `请完成人机验证`, 's15e33945d13c5176': `上次提交结果正在确认,请稍后重试;图片会被保留。`, +'s176fe95698857ad9': `评论或回复已发生变化。请先复制草稿,再重新打开编辑框获取最新版本。`, 's1c6fefb092506753': `加载评论列表失败,请稍后重试`, 's1d468f888124a55e': `草稿已更改,请重新打开后再重试。`, 's1e3e30a26025484c': `加载回复列表失败,请稍后重试`, +'s2254107bb49631da': `该评论或回复已被删除。`, +'s22ae36191f9a1a8d': `加载最新内容失败,请稍后重试。`, 's2406b89e991a4524': `刷新验证码`, 's26e4d65f2801ac9c': `请输入内容`, 's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`, +'s2c8189544e3ea679': `重试`, 's2ca7699f7c5a4996': `管理`, +'s2ceb11be2290bb1b': `取消`, +'s2df0abe59a97747a': `确定要丢弃所做的修改吗?`, 's2e192b19ed15fcf6': `页码`, +'s33f85f24c0f5f008': `保存`, 's34239948d1de01f1': `验证暂不可用,点击重试`, 's35c3125c7750681e': `引用`, 's3643189d1abbb7f4': `代码`, 's3fb33d17bad61aa9': `评论成功,请等待审核`, 's44851a8adf059eef': `置顶`, 's4c0e15f9073382e6': `获取验证码失败`, +'s4d76c6944ffb0540': `保存失败。该评论可能已被删除,或当前 Halo 版本不支持编辑。你的草稿已保留。`, 's5184a3f3e2f7b603': `当前是匿名状态,选择私密选项后,评论将仅对网站管理员可见。`, 's523eb9043213ff0d': `斜体`, 's5787e20cab57b383': `未知错误`, @@ -41,6 +49,8 @@ 's59b95f09e3b42060': `人机验证未通过,请重新验证后提交。`, 's5fc35a09a85fe63e': `关闭图片`, 's63ce0636351bf780': `上传结果不完整,请重试。`, +'s63efea23113001ee': `保存失败,草稿已保留。`, +'s64ef2a6c2dd1d3d1': `编辑`, 's67749057edb2586b': `退出登录`, 's6cb61eeccda272d5': `代码块`, 's6f23997fffbecc6a': `取消通过`, @@ -62,6 +72,7 @@ 'sa10fbe3fcd6ae148': `加入回复`, 'sa3443b99ecf186ea': `取消隐藏`, 'sa82fbb7f361c6f44': `显示回复`, +'sa862e76e95967816': `评论更新成功`, 'sa8dddacbaa66f8e0': `请先登录`, 'sb206d700d26b14ff': `下划线`, 'sb3d4f79d9d8b71e5': `提交评论`, diff --git a/packages/comment-widget/src/generated/locales/zh-TW.ts b/packages/comment-widget/src/generated/locales/zh-TW.ts index cc6c064..3548a35 100644 --- a/packages/comment-widget/src/generated/locales/zh-TW.ts +++ b/packages/comment-widget/src/generated/locales/zh-TW.ts @@ -20,20 +20,28 @@ 's0fbf6dc6a1966408': `下一頁`, 's107ccef507b51f2c': `請完成人機驗證`, 's15e33945d13c5176': `上次提交結果正在確認,請稍後重試;圖片會被保留。`, +'s176fe95698857ad9': `評論或回覆已發生變更。請先複製草稿,再重新開啟編輯框取得最新版本。`, 's1c6fefb092506753': `載入評論列表失敗,請稍後重試`, 's1d468f888124a55e': `草稿已變更,請重新開啟後再重試。`, 's1e3e30a26025484c': `載入回覆列表失敗,請稍後重試`, +'s2254107bb49631da': `該評論或回覆已被刪除。`, +'s22ae36191f9a1a8d': `載入最新內容失敗,請稍後再試。`, 's2406b89e991a4524': `重新整理驗證碼`, 's26e4d65f2801ac9c': `請輸入內容`, 's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`, +'s2c8189544e3ea679': `重試`, 's2ca7699f7c5a4996': `管理`, +'s2ceb11be2290bb1b': `取消`, +'s2df0abe59a97747a': `確定要捨棄所做的修改嗎?`, 's2e192b19ed15fcf6': `頁碼`, +'s33f85f24c0f5f008': `儲存`, 's34239948d1de01f1': `驗證暫不可用,點擊重試`, 's35c3125c7750681e': `引用`, 's3643189d1abbb7f4': `程式碼`, 's3fb33d17bad61aa9': `評論成功,請等待審核`, 's44851a8adf059eef': `置頂`, 's4c0e15f9073382e6': `獲取驗證碼失敗`, +'s4d76c6944ffb0540': `儲存失敗。該評論可能已被刪除,或目前 Halo 版本不支援編輯。你的草稿已保留。`, 's5184a3f3e2f7b603': `目前是匿名狀態,選擇私密選項後,評論將僅對網站管理員可見。`, 's523eb9043213ff0d': `斜體`, 's5787e20cab57b383': `未知錯誤`, @@ -41,6 +49,8 @@ 's59b95f09e3b42060': `人機驗證未通過,請重新驗證後提交。`, 's5fc35a09a85fe63e': `關閉圖片`, 's63ce0636351bf780': `上傳結果不完整,請重試。`, +'s63efea23113001ee': `儲存失敗,草稿已保留。`, +'s64ef2a6c2dd1d3d1': `編輯`, 's67749057edb2586b': `登出`, 's6cb61eeccda272d5': `程式碼區塊`, 's6f23997fffbecc6a': `取消通過`, @@ -62,6 +72,7 @@ 'sa10fbe3fcd6ae148': `新增回覆`, 'sa3443b99ecf186ea': `取消隱藏`, 'sa82fbb7f361c6f44': `顯示回覆`, +'sa862e76e95967816': `評論更新成功`, 'sa8dddacbaa66f8e0': `請先登入`, 'sb206d700d26b14ff': `底線`, 'sb3d4f79d9d8b71e5': `提交評論`, diff --git a/packages/comment-widget/src/index.ts b/packages/comment-widget/src/index.ts index cdba679..0c83c8a 100644 --- a/packages/comment-widget/src/index.ts +++ b/packages/comment-widget/src/index.ts @@ -1,6 +1,7 @@ import { BaseCommentItem } from './base-comment-item'; import { BaseForm } from './base-form'; import { CommentContent } from './comment-content'; +import { CommentEditForm } from './comment-edit-form'; import { CommentEditor } from './comment-editor'; import { CommentItem } from './comment-item'; import { CommentList } from './comment-list'; @@ -19,6 +20,7 @@ export { BaseCommentItem, BaseForm, CommentContent, + CommentEditForm, CommentEditor, CommentItem, CommentList, diff --git a/packages/comment-widget/src/reply-item.ts b/packages/comment-widget/src/reply-item.ts index 06f4a53..f694f31 100644 --- a/packages/comment-widget/src/reply-item.ts +++ b/packages/comment-widget/src/reply-item.ts @@ -6,6 +6,7 @@ import baseStyles from './styles/base'; import './user-avatar'; import './base-comment-item'; import './comment-management'; +import './comment-edit-form'; import './reply-form'; import { consume } from '@lit/context'; import { msg } from '@lit/localize'; @@ -35,6 +36,9 @@ export class ReplyItem extends LitElement { @state() showReplyForm = false; + @state() + showEditForm = false; + @state() upvoted = false; @@ -84,6 +88,15 @@ export class ReplyItem extends LitElement { this.showReplyForm = false; } + private async handleCloseEditForm() { + this.showEditForm = false; + await this.updateComplete; + this.renderRoot + .querySelector('comment-management') + ?.shadowRoot?.querySelector('summary') + ?.focus({ preventScroll: true }); + } + onReplyCreated( event: CustomEvent<{ resetForm: (form: BaseForm) => boolean }> ) { @@ -170,6 +183,7 @@ export class ReplyItem extends LitElement { .userWebsite=${this.reply?.spec.owner.annotations?.website} .ua=${this.reply?.spec.userAgent} .private=${this.comment?.spec.hidden || this.reply?.spec.hidden} + .editing=${this.showEditForm} > - + (this.showEditForm = true)}> + ${when( + this.showEditForm, + () => html`` + )} ${when( this.showReplyForm, () => html`
diff --git a/packages/comment-widget/src/utils/comment-management.ts b/packages/comment-widget/src/utils/comment-management.ts index 434fc62..7f7bd64 100644 --- a/packages/comment-widget/src/utils/comment-management.ts +++ b/packages/comment-widget/src/utils/comment-management.ts @@ -1,4 +1,9 @@ -import type { ListedReplyList, UserPermission } from '@halo-dev/api-client'; +import type { + Comment, + ListedReplyList, + Reply, + UserPermission, +} from '@halo-dev/api-client'; import { ofetch } from 'ofetch'; export type ManagementAction = @@ -62,8 +67,37 @@ export async function manageComment( }); } +export interface CommentContentUpdate { + raw: string; + content: string; + version: number; +} + +export async function fetchCommentContent( + baseUrl: string, + resource: 'comments' | 'replies', + name: string +) { + return ofetch( + `${baseUrl}/apis/content.halo.run/v1alpha1/${resource}/${encodeURIComponent(name)}`, + { retry: 0 } + ); +} + +export async function updateCommentContent( + baseUrl: string, + resource: 'comments' | 'replies', + name: string, + body: CommentContentUpdate +) { + await ofetch( + `${baseUrl}/apis/api.console.halo.run/v1alpha1/${resource}/${encodeURIComponent(name)}/content`, + { method: 'PUT', body, retry: 0 } + ); +} + export interface CommentManagedDetail { - action: ManagementAction; + action: ManagementAction | 'edit'; restoreFocus: boolean; commentName?: string; } diff --git a/packages/comment-widget/src/utils/html.ts b/packages/comment-widget/src/utils/html.ts index bc9cc87..e20a734 100644 --- a/packages/comment-widget/src/utils/html.ts +++ b/packages/comment-widget/src/utils/html.ts @@ -12,3 +12,38 @@ export function cleanHtml(content?: string) { }, }); } + +const htmlTagPattern = /<\/?[a-zA-Z][^>]*>/; + +const entityPattern = /&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)/g; + +// Console's plain-text editor stores raw text without markup, displayed with +// `white-space: pre-wrap`. Tiptap parses initial content as HTML and would +// collapse newlines and consecutive spaces, so plain text is converted to +// hard breaks with preserved whitespace first, matching that rendering. +export function toEditorContent(raw: string) { + // Angle-bracket text that the sanitizer would strip (e.g. ``) + // is treated as plain text; only markup that survives display cleaning is + // parsed as HTML, matching what the comment actually renders as. + if (htmlTagPattern.test(cleanHtml(raw))) { + return raw; + } + return raw + .replace(entityPattern, '&') + .replace(//g, '>') + .replace(/\r\n?/g, '\n') + .replace(/\t/g, '\u00a0\u00a0\u00a0\u00a0') + .split('\n') + .map((line) => + line + .replace(/ {2,}/g, (run) => + run + .split('') + .map((char, index) => (index % 2 ? char : '\u00a0')) + .join('') + ) + .replace(/^ | $/g, '\u00a0') + ) + .join('
'); +} diff --git a/packages/comment-widget/tests/comment-management.test.ts b/packages/comment-widget/tests/comment-management.test.ts index ee24655..0309ec1 100644 --- a/packages/comment-widget/tests/comment-management.test.ts +++ b/packages/comment-widget/tests/comment-management.test.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { test } from 'vitest'; import { + fetchCommentContent, fetchManagementPermission, manageComment, + updateCommentContent, } from '../src/utils/comment-management.ts'; test('management permissions fail closed and mutations follow Halo contracts', async ({ @@ -104,3 +106,68 @@ test('management permissions fail closed and mutations follow Halo contracts', a await assert.rejects(manageComment(baseUrl, 'comments', 'name', 'delete')); assert.equal(requests.length, count + 1); }); + +test('content editing endpoints follow Halo contracts', async ({ + onTestFinished, +}) => { + const requests: { + method?: string; + url?: string; + type?: string; + body: unknown; + }[] = []; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + requests.push({ + method: req.method, + url: req.url, + type: req.headers['content-type'], + body: body ? JSON.parse(body) : undefined, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + metadata: { name: 'name', version: 7 }, + spec: { raw: '

hi

', content: '

hi

' }, + }) + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + onTestFinished( + () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + ); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + for (const resource of ['comments', 'replies'] as const) { + const latest = await fetchCommentContent( + baseUrl, + resource, + 'name/with space' + ); + assert.equal(latest.metadata.version, 7); + assert.deepEqual(requests.at(-1), { + method: 'GET', + url: `/apis/content.halo.run/v1alpha1/${resource}/name%2Fwith%20space`, + type: undefined, + body: undefined, + }); + + await updateCommentContent(baseUrl, resource, 'name/with space', { + raw: '

edited

', + content: '

edited

', + version: 7, + }); + assert.deepEqual(requests.at(-1), { + method: 'PUT', + url: `/apis/api.console.halo.run/v1alpha1/${resource}/name%2Fwith%20space/content`, + type: 'application/json', + body: { raw: '

edited

', content: '

edited

', version: 7 }, + }); + } +}); diff --git a/packages/comment-widget/tests/editor-content.test.ts b/packages/comment-widget/tests/editor-content.test.ts new file mode 100644 index 0000000..b7cfb24 --- /dev/null +++ b/packages/comment-widget/tests/editor-content.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { toEditorContent } from '../src/utils/html.ts'; + +test('toEditorContent keeps HTML raw unchanged', () => { + const html = '

first

second  line

'; + assert.equal(toEditorContent(html), html); +}); + +test('toEditorContent converts plain text lines to hard breaks without losing whitespace', () => { + assert.equal( + toEditorContent('first line\nsecond line'), + 'first line
second\u00a0 line' + ); +}); + +test('toEditorContent preserves blank lines and edge spaces', () => { + assert.equal( + toEditorContent(' leading\n\ntrailing '), + '\u00a0leading

trailing\u00a0' + ); +}); + +test('toEditorContent escapes plain text that looks like markup', () => { + assert.equal(toEditorContent('1 < 2 & 3 > 2'), '1 < 2 & 3 > 2'); +}); + +test('toEditorContent keeps existing entities encoded once', () => { + assert.equal( + toEditorContent('A < B & C A'), + 'A < B & C A' + ); +}); + +test('toEditorContent normalizes CRLF line endings', () => { + assert.equal(toEditorContent('a\r\nb\rc'), 'a
b
c'); +}); + +test('toEditorContent expands tabs to preserved spaces', () => { + assert.equal(toEditorContent('a\tb'), 'a\u00a0\u00a0\u00a0\u00a0b'); +}); + +test('toEditorContent escapes angle-bracket text that cleaning would strip', () => { + assert.equal( + toEditorContent('see '), + 'see <https://x.com>' + ); +}); + +test('toEditorContent keeps in-prose tags that survive cleaning as HTML', () => { + assert.equal(toEditorContent('use the

tag'), 'use the

tag'); +}); diff --git a/packages/comment-widget/xliff/es.xlf b/packages/comment-widget/xliff/es.xlf index 8905730..cfa41ba 100644 --- a/packages/comment-widget/xliff/es.xlf +++ b/packages/comment-widget/xliff/es.xlf @@ -302,6 +302,50 @@ Zoom image Ampliar imagen + + Comment updated successfully + Comentario actualizado correctamente + + + This comment or reply has changed. Copy your draft, then reopen the editor to load the latest version. + El comentario o la respuesta ha cambiado. Copia tu borrador y vuelve a abrir el editor para cargar la última versión. + + + Could not save. Your draft has been kept. + No se pudo guardar. Se ha conservado tu borrador. + + + This comment or reply has been deleted. + Este comentario o respuesta ha sido eliminado. + + + Failed to load the latest content. Please try again later. + No se pudo cargar el contenido más reciente. Inténtalo de nuevo más tarde. + + + Save + Guardar + + + Cancel + Cancelar + + + Edit + Editar + + + Discard your changes? + ¿Descartar los cambios? + + + Retry + Reintentar + + + Could not save. The comment may have been deleted, or the current Halo version does not support editing. Your draft has been kept. + No se pudo guardar. Es posible que el comentario se haya eliminado o que la versión actual de Halo no admita la edición. Tu borrador se ha conservado. + diff --git a/packages/comment-widget/xliff/zh-CN.xlf b/packages/comment-widget/xliff/zh-CN.xlf index df62c1b..7ef1d86 100644 --- a/packages/comment-widget/xliff/zh-CN.xlf +++ b/packages/comment-widget/xliff/zh-CN.xlf @@ -302,6 +302,50 @@ Zoom image 放大图片 + + Comment updated successfully + 评论更新成功 + + + This comment or reply has changed. Copy your draft, then reopen the editor to load the latest version. + 评论或回复已发生变化。请先复制草稿,再重新打开编辑框获取最新版本。 + + + Could not save. Your draft has been kept. + 保存失败,草稿已保留。 + + + This comment or reply has been deleted. + 该评论或回复已被删除。 + + + Failed to load the latest content. Please try again later. + 加载最新内容失败,请稍后重试。 + + + Save + 保存 + + + Cancel + 取消 + + + Edit + 编辑 + + + Discard your changes? + 确定要丢弃所做的修改吗? + + + Retry + 重试 + + + Could not save. The comment may have been deleted, or the current Halo version does not support editing. Your draft has been kept. + 保存失败。该评论可能已被删除,或当前 Halo 版本不支持编辑。你的草稿已保留。 + diff --git a/packages/comment-widget/xliff/zh-TW.xlf b/packages/comment-widget/xliff/zh-TW.xlf index 10e2fc4..51b48a2 100644 --- a/packages/comment-widget/xliff/zh-TW.xlf +++ b/packages/comment-widget/xliff/zh-TW.xlf @@ -302,6 +302,50 @@ Zoom image 放大圖片 + + Comment updated successfully + 評論更新成功 + + + This comment or reply has changed. Copy your draft, then reopen the editor to load the latest version. + 評論或回覆已發生變更。請先複製草稿,再重新開啟編輯框取得最新版本。 + + + Could not save. Your draft has been kept. + 儲存失敗,草稿已保留。 + + + This comment or reply has been deleted. + 該評論或回覆已被刪除。 + + + Failed to load the latest content. Please try again later. + 載入最新內容失敗,請稍後再試。 + + + Save + 儲存 + + + Cancel + 取消 + + + Edit + 編輯 + + + Discard your changes? + 確定要捨棄所做的修改嗎? + + + Retry + 重試 + + + Could not save. The comment may have been deleted, or the current Halo version does not support editing. Your draft has been kept. + 儲存失敗。該評論可能已被刪除,或目前 Halo 版本不支援編輯。你的草稿已保留。 +