Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/dist/roundtable.css

Large diffs are not rendered by default.

38 changes: 18 additions & 20 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function fetchComments(caseId) {
/**
* Post a comment on a public topic.
* @param {string} caseId
* @param {string} body markdown comment body
* @param {string} body HTML comment body (the proxy converts it to markdown server-side)
* @returns {Promise<{comment:object}|{error:{kind:string}}>}
*/
export function postComment(caseId, body) {
Expand Down
150 changes: 24 additions & 126 deletions assets/src/comment-editor.jsx
Original file line number Diff line number Diff line change
@@ -1,145 +1,43 @@
/** @jsx h */
import { h } from 'preact';
import { useRef, useState } from 'preact/hooks';
import { renderMarkdown } from './markdown.js';

const TOOLS = [
{ id: 'bold', title: 'Bold', wrap: ['**', '**'], label: <b>B</b> },
{ id: 'italic', title: 'Italic', wrap: ['_', '_'], label: <i>I</i> },
{
id: 'code', title: 'Inline code', wrap: ['`', '`'],
label: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 8-4 4 4 4M16 8l4 4-4 4" /></svg>,
},
{
id: 'link', title: 'Link', wrap: ['[', '](url)'],
label: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 15l6-6" /><path d="M11 6l1-1a4 4 0 0 1 6 6l-1 1" /><path d="M13 18l-1 1a4 4 0 0 1-6-6l1-1" /></svg>,
},
{ id: 'sep', sep: true },
{
id: 'list', title: 'Bulleted list', prefix: '- ',
label: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 6h11M9 12h11M9 18h11" /><circle cx="4.5" cy="6" r="1.1" fill="currentColor" stroke="none" /><circle cx="4.5" cy="12" r="1.1" fill="currentColor" stroke="none" /><circle cx="4.5" cy="18" r="1.1" fill="currentColor" stroke="none" /></svg>,
},
{
id: 'quote', title: 'Quote', prefix: '> ',
label: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 5v14" /><path d="M11 8h7M11 12h7M11 16h5" /></svg>,
},
{
id: 'fence', title: 'Code block', wrap: ['```\n', '\n```'],
label: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m9 10-2 2 2 2M15 10l2 2-2 2" /></svg>,
},
];
import { useState } from 'preact/hooks';
import { htmlHasText, uniqueEditorId, WysiwygEditor } from './wysiwyg-editor.jsx';

/**
* @param {HTMLTextAreaElement} el
* @param {[string,string]} wrap
* @param {string} value
*/
function applyWrap(el, wrap, value) {
const [before, after] = wrap;
const start = el.selectionStart;
const end = el.selectionEnd;
const sel = value.slice(start, end);
const next = value.slice(0, start) + before + sel + after + value.slice(end);
const cursor = start + before.length + sel.length;
return { next, cursor };
}

/**
* @param {HTMLTextAreaElement} el
* @param {string} prefix
* @param {string} value
*/
function applyPrefix(el, prefix, value) {
const start = el.selectionStart;
const end = el.selectionEnd;
const block = value.slice(start, end) || '';
const lines = block.split('\n');
const prefixed = lines.map((line) => prefix + line).join('\n');
const next = value.slice(0, start) + prefixed + value.slice(end);
return { next, cursor: start + prefixed.length };
}

/**
* Full markdown comment composer with Write/Preview and toolbar.
* @param {{value:string, onInput:(v:string)=>void, onSubmit:()=>void, posting:boolean, error:boolean, submitDisabled?:boolean}} props
* Comment composer built on WordPress's bundled TinyMCE (via `wp.editor`),
* restricted to bold/italic/lists/link — no headings or block-format select
* (see WysiwygEditor). Falls back to a plain textarea when TinyMCE isn't
* available.
* @param {{value:string, onInput:(v:string)=>void, onSubmit:()=>void, posting:boolean, error:boolean, submitDisabled?:boolean, resetNonce?:number}} props
*/
export function CommentEditor({
value, onInput, onSubmit, posting, error, submitDisabled = false,
value, onInput, onSubmit, posting, error, submitDisabled = false, resetNonce = 0,
}) {
const [mode, setMode] = useState('write');
const areaRef = useRef(null);

function applyTool(tool) {
if (tool.sep) return;
const el = areaRef.current;
if (!el || mode !== 'write') return;
let result;
if (tool.wrap) result = applyWrap(el, tool.wrap, value);
else if (tool.prefix) result = applyPrefix(el, tool.prefix, value);
else return;
onInput(result.next);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(result.cursor, result.cursor);
});
}

function onKeyDown(e) {
if (mode !== 'write' || posting || submitDisabled) return;
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (value.trim()) onSubmit();
}
}
const [editorId] = useState(() => uniqueEditorId('rt-comment'));
const canSubmit = htmlHasText(value);

return (
<div class="rt-cm-composer">
<div class="rt-editor rt-mdeditor">
<div class="rt-ed-top">
<div class="rt-ed-tabs">
<button type="button" class={'rt-ed-tab' + (mode === 'write' ? ' on' : '')} onClick={() => setMode('write')}>Write</button>
<button type="button" class={'rt-ed-tab' + (mode === 'preview' ? ' on' : '')} onClick={() => setMode('preview')}>Preview</button>
</div>
<span class="rt-ed-grow" />
{mode === 'write' && (
<div class="rt-ed-tools">
{TOOLS.map((t) => t.sep
? <span key={t.id} class="rt-ed-sep" />
: (
<button key={t.id} type="button" class="rt-ed-tool" title={t.title} disabled={posting} onClick={() => applyTool(t)}>
{t.label}
</button>
))}
</div>
)}
</div>
{mode === 'write' ? (
<textarea
ref={areaRef}
class="rt-ed-input"
placeholder="Add a comment…"
rows="4"
value={value}
disabled={posting}
onInput={(e) => onInput(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
) : (
<div
class="rt-ed-preview rt-prose"
dangerouslySetInnerHTML={{ __html: value.trim() ? renderMarkdown(value) : '<p class="rt-ed-preview-empty">Nothing to preview yet.</p>' }}
/>
)}
<WysiwygEditor
id={editorId}
value={value}
onInput={onInput}
disabled={posting}
toolbar1="bold italic bullist numlist link"
height={160}
rows={4}
placeholder="Add a comment…"
className="rt-ed-input"
resetNonce={resetNonce}
/>
<div class="rt-ed-foot">
<span class="rt-ed-md" title="Markdown is supported" aria-label="Markdown supported">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3ZM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6Zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5Z" /></svg>
</span>
{error ? <span class="rt-ed-error">Could not post. Try again.</span> : <span class="rt-ed-spacer" />}
<button type="button" class="rt-post-btn" disabled={posting || !value.trim() || mode !== 'write' || submitDisabled} onClick={onSubmit}>
<button type="button" class="rt-post-btn" disabled={posting || !canSubmit || submitDisabled} onClick={onSubmit}>
{posting ? 'Posting…' : 'Comment'}
</button>
</div>
</div>
</div>
);
}
}
16 changes: 11 additions & 5 deletions assets/src/publish-dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Fragment, h } from 'preact';
import { useState } from 'preact/hooks';
import { renderMarkdown } from './markdown.js';
import { htmlHasText, uniqueEditorId, WysiwygEditor } from './wysiwyg-editor.jsx';

const TYPES = [
{ id: 'question', label: 'Question', badgeClass: 'rt-b-q' },
Expand Down Expand Up @@ -47,9 +48,10 @@ export function PublishDialog({
const [summary, setSummary] = useState(draft?.summary || '');
const [type, setType] = useState(isDirect ? '' : normalizeType(draft?.type));
const [mode, setMode] = useState('write');
const [editorId] = useState(() => uniqueEditorId('rt-topic-body'));
const activeType = TYPES.find((t) => t.id === type) || TYPES[0];
const canSubmit = isDirect
? Boolean(type) && title.trim() && summary.trim()
? Boolean(type) && title.trim() && htmlHasText(summary)
: title.trim() && summary.trim() && mode === 'write';

return (
Expand Down Expand Up @@ -94,13 +96,17 @@ export function PublishDialog({
</label>
<div class="rt-pub-body">
{isDirect ? (
<textarea
class="rt-pub-textarea"
rows="14"
<WysiwygEditor
id={editorId}
value={summary}
onInput={setSummary}
disabled={busy}
toolbar1="formatselect bold italic bullist numlist link code"
blockFormats="Paragraph=p;Heading=h3;Subheading=h4"
height={220}
rows={14}
placeholder="Describe your question, bug, or feature idea…"
onInput={(e) => setSummary(e.currentTarget.value)}
className="rt-pub-textarea"
/>
) : (
<Fragment>
Expand Down
18 changes: 7 additions & 11 deletions assets/src/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions assets/src/topic-detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { mapCommentToView } from './topics.js';
import { DisabledVote, VoteControl } from './vote-control.jsx';
import { CommentEditor } from './comment-editor.jsx';
import { CommentActions } from './comment-actions.jsx';
import { htmlHasText } from './wysiwyg-editor.jsx';

function CommentRow({ comment }) {
const Ava = comment.isAgent ? AgentAvatar : PersonAvatar;
Expand Down Expand Up @@ -42,20 +43,20 @@ function CommentComposer({ caseId, onPosted, disabled = false }) {
const [text, setText] = useState('');
const [posting, setPosting] = useState(false);
const [error, setError] = useState(false);
const [resetNonce, setResetNonce] = useState(0);

const submit = async () => {
if (disabled) return;
const body = text.trim();
if (!body || posting) return;
if (disabled || posting || !htmlHasText(text)) return;
setPosting(true);
setError(false);
const res = await postComment(caseId, body);
const res = await postComment(caseId, text.trim());
setPosting(false);
if (res.error) {
setError(true);
return;
}
setText('');
setResetNonce((n) => n + 1);
onPosted();
};

Expand All @@ -67,6 +68,7 @@ function CommentComposer({ caseId, onPosted, disabled = false }) {
posting={posting}
error={error}
submitDisabled={disabled}
resetNonce={resetNonce}
/>
);
}
Expand Down
Loading
Loading