Skip to content

Commit f6f7637

Browse files
committed
feat(editor): description field gets Markdown detection on load, and an inline Edit/Preview view
- fromEventJson now converts a description that already contains HTML (legacy files, or written by other tooling) to Markdown on load, same detection packages/import-ics and packages/import-jsonld already use for fresh imports. - The description field gets an always-visible Edit/Preview toggle plus an "expand" icon opening a larger view for long descriptions — no new stored format, still plain Markdown text underneath. - Fixes the toggle/expand not wiring up when Description is added via its own "+ Description" chip on a new event, rather than already visible on load — threaded the section's onRebuilt callback (onSectionRebuilt) through both the Where and What/Who/Metadata chippable sections instead of just Where's geo map.
1 parent f199a5c commit f6f7637

9 files changed

Lines changed: 424 additions & 8 deletions

File tree

apps/editor/CLAUDE.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ repeatedly in the same session: `#profile-switch`, `.recurrence-fields`,
2525
`.field.pair`. When adding a new `display:` rule on anything toggled by
2626
`.hidden = …` in `main.ts`/`ui/form.ts`, default to `:not([hidden])`.
2727

28+
Same bug, different attribute: a native `<dialog>` relies on the UA
29+
stylesheet's `dialog:not([open]) { display: none }` to stay invisible
30+
until `showModal()`. An unconditional `#my-dialog { display: flex; ... }`
31+
(needed for a dialog whose content should fill it, e.g. a tall
32+
textarea) overrides that too — the dialog then renders inline in the
33+
page's normal flow instead of staying hidden until opened
34+
(`#description-editor-dialog` hit this). Scope it `#my-dialog[open] {
35+
display: flex; ... }` instead.
36+
2837
## Native date/time inputs have a rendering-width floor
2938

3039
`<input type="date">`/`<input type="time">` won't shrink below their own
@@ -71,6 +80,34 @@ a prefilled URL — unlike single-event `proposeChangeUrl`, there's no small-N
7180
case worth optimizing for: `MAX_URL_LENGTH` (8000) is reliably exceeded well
7281
before a handful of full event JSON blocks (~2-2.5KB each) fit in one URL.
7382

83+
## A chippable section's "+ field" chip rebuilds its own subtree, not the whole form
84+
85+
Optional fields in the What/Who/Where/Metadata sections (Description,
86+
Venue, Tags, …) live behind a "+ Field" chip (`renderChippableSection` in
87+
`ui/form.ts`) — clicking it (or removing the field, or a dependent field
88+
reacting to its driver) calls that section's own internal `renderBlocks()`,
89+
**not** `main.ts`'s top-level `render()`. Anything that needs to run once
90+
a field's DOM actually exists — attaching listeners to a native element
91+
that can't be wired declaratively (the geo map, the description
92+
toolbar/expand button) — must not assume `render()`'s own end-of-function
93+
mounting calls (`mountMap()`, `mountDescriptionExpand()`, …) ever ran for
94+
that field: they didn't, because the chip never called `render()`.
95+
96+
The fix is `renderChippableSection`'s `onRebuilt` param, threaded through
97+
`renderForm` as `onSectionRebuilt` and implemented in `main.ts` as one
98+
function that (re)runs every such mount call — `main.ts` passes the same
99+
`onSectionRebuilt` for both the Where and What/Who/Metadata sections
100+
rather than a per-field callback, since each mount function already
101+
no-ops harmlessly when its own slot isn't present
102+
(`form.querySelector(...); if (!slot) return;`). When adding a new field
103+
whose behavior needs post-render wiring, add its mount call to
104+
`onSectionRebuilt` in `main.ts`, not just to the bottom of `render()`
105+
otherwise it works when the field is already visible on load (edit an
106+
existing event) but silently does nothing the first time it's added via
107+
its own "+" chip (a new event, or any field not part of the active
108+
profile's default set) — bitten by this with the description
109+
toolbar/expand button.
110+
74111
## OTE has no recurrence-rule concept
75112

76113
The spec is explicit: one document per occurrence, always ("un documento

apps/editor/index.html

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,46 @@ <h2 data-i18n="dialog.recurrenceCustom.title">Custom recurrence</h2>
746746
</div>
747747
</dialog>
748748

749+
<!-- The compact Description textarea's "⤢ Expand" button (rendered
750+
in ui/form.ts's renderField) opens this: a much larger view with
751+
an Edit/Preview toggle, for writing/reviewing a long description
752+
as a whole. Edits write straight to state.description live (see
753+
main.ts) — no separate confirm step, same as the geo map. -->
754+
<dialog id="description-editor-dialog">
755+
<h2 data-i18n="dialog.descriptionEditor.title">Description</h2>
756+
757+
<div
758+
class="mode-toggle"
759+
id="description-editor-mode"
760+
role="radiogroup"
761+
aria-label="View"
762+
data-i18n-aria-label="dialog.descriptionEditor.title"
763+
>
764+
<label>
765+
<input type="radio" name="description-editor-mode" value="edit" checked />
766+
<span data-i18n="dialog.descriptionEditor.editTab">Edit</span>
767+
</label>
768+
<label>
769+
<input type="radio" name="description-editor-mode" value="preview" />
770+
<span data-i18n="dialog.descriptionEditor.previewTab">Preview</span>
771+
</label>
772+
</div>
773+
774+
<textarea
775+
id="description-editor-textarea"
776+
rows="16"
777+
aria-label="Description"
778+
data-i18n-aria-label="dialog.descriptionEditor.title"
779+
></textarea>
780+
<div id="description-editor-preview" class="description-preview" hidden></div>
781+
782+
<div class="actions">
783+
<button id="description-editor-done" type="button" class="primary" data-i18n="action.done">
784+
Done
785+
</button>
786+
</div>
787+
</dialog>
788+
749789
<section id="fallback" hidden>
750790
<h3 data-i18n="fallback.title">URL too long — copy manually</h3>
751791
<p data-i18n="fallback.hint">

apps/editor/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"@opentechevents/import-ics": "workspace:*",
1717
"@opentechevents/import-jsonld": "workspace:*",
1818
"@opentechevents/validate": "workspace:*",
19-
"leaflet": "^1.9.4"
19+
"leaflet": "^1.9.4",
20+
"marked": "^18.0.9"
2021
},
2122
"devDependencies": {
2223
"@types/leaflet": "^1.9.22",

apps/editor/src/i18n/es.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ export const es: Record<string, string> = {
116116
"dialog.recurrence.missingPartOfAck":
117117
'Entendido, continuar sin rellenar "Part of (series)"',
118118

119+
// --- description "expand" dialog -----------------------------------------
120+
"dialog.descriptionEditor.title": "Descripción",
121+
"dialog.descriptionEditor.editTab": "Editar",
122+
"dialog.descriptionEditor.previewTab": "Vista previa",
123+
119124
// --- recurrence: inline row list in "Cuándo" ----------------------------
120125
"dialog.recurrenceRow.label": "Se repite",
121126
"dialog.recurrenceRow.addLabel": "+ Añadir recurrencia",
@@ -213,6 +218,7 @@ export const es: Record<string, string> = {
213218
"No se pudo determinar el nombre de archivo de este evento a partir del feed.",
214219
"action.edit": "Editar",
215220
"action.duplicate": "Duplicar",
221+
"action.expandDescription": "Ampliar",
216222
"action.delete": "Eliminar",
217223
"action.deleteDirect": "Eliminar en GitHub",
218224
"action.deleteViaIssue": "Proponer eliminación",

apps/editor/src/lib/event-json.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
import { htmlToMarkdown, looksLikeHtml } from "@opentechevents/import-ics";
2+
13
import type { FormState, OteConfig, OteEvent } from "./types.js";
24

5+
/** Converts to Markdown only when the value actually looks like HTML — a
6+
* safety net for a description read from a file written before this
7+
* conversion existed, or by another tool (fresh imports already convert at
8+
* import time, see packages/import-ics and packages/import-jsonld). */
9+
function normalizeDescription(value: string): string {
10+
return looksLikeHtml(value) ? htmlToMarkdown(value) : value;
11+
}
12+
313
/** A fresh, empty form. `timezone` is injected by the caller (browser TZ). */
414
export function emptyFormState(timezone = ""): FormState {
515
return {
@@ -284,7 +294,7 @@ export function fromEventJson(json: OteEvent, slug: string): FormState {
284294
slug,
285295
id: json.id ?? "",
286296
name: json.name ?? "",
287-
description: json.description ?? "",
297+
description: normalizeDescription(json.description ?? ""),
288298
url: json.url ?? "",
289299
tags: (json.tags ?? []).join(", "),
290300
languages: (json.languages ?? []).join(", "),
@@ -348,7 +358,7 @@ export function fromEventJson(json: OteEvent, slug: string): FormState {
348358
translations: Object.fromEntries(
349359
Object.entries(json.translations ?? {}).map(([lang, entry]) => [
350360
lang,
351-
{ name: entry.name ?? "", description: entry.description ?? "" },
361+
{ name: entry.name ?? "", description: normalizeDescription(entry.description ?? "") },
352362
]),
353363
),
354364
eligibilityNoteTranslations: unwrapTranslations(

apps/editor/src/main.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { icsToEvents } from "@opentechevents/import-ics";
88
import { htmlToEvents } from "@opentechevents/import-jsonld";
9+
import { marked } from "marked";
910

1011
import { loadAdopters, type Adopter } from "./lib/adopters.js";
1112
import { findCollisions } from "./lib/collisions.js";
@@ -988,6 +989,87 @@ async function startEditor(repo: string | null): Promise<void> {
988989
);
989990
}
990991

992+
// --- description: inline Edit/Preview toggle (always visible, see
993+
// ui/form.ts's fieldId === "description" block) + a "⤢ expand" icon
994+
// opening a larger dialog for the same textarea. Both write straight to
995+
// state.description live, no separate confirm step — same as the geo
996+
// map. Wired the same way mountMap wires the geo slot: main.ts finds the
997+
// elements ui/form.ts built after each render, since they don't exist
998+
// beforehand. ---
999+
const descriptionEditorDialog = el<HTMLDialogElement>("description-editor-dialog");
1000+
const descriptionEditorTextarea = el<HTMLTextAreaElement>("description-editor-textarea");
1001+
const descriptionEditorPreview = el<HTMLDivElement>("description-editor-preview");
1002+
1003+
function renderDescriptionPreview(target: HTMLElement, source: string): void {
1004+
target.innerHTML = marked.parse(source, { async: false });
1005+
}
1006+
1007+
/** The compact field's own preview (if that's its active tab) doesn't
1008+
* see edits made from the larger dialog — its textarea has its own input
1009+
* listener, but the dialog writes state.description directly. Called
1010+
* after every dialog edit to keep the two in sync. */
1011+
function syncInlineDescriptionPreview(): void {
1012+
const preview = form.querySelector<HTMLDivElement>(
1013+
'[data-role="description-inline-preview"]',
1014+
);
1015+
if (!preview || preview.hidden) return;
1016+
renderDescriptionPreview(preview, state.description);
1017+
}
1018+
1019+
descriptionEditorTextarea.addEventListener("input", () => {
1020+
onInput("description", descriptionEditorTextarea.value);
1021+
setControlValue("description", descriptionEditorTextarea.value);
1022+
syncInlineDescriptionPreview();
1023+
});
1024+
1025+
el<HTMLDivElement>("description-editor-mode").addEventListener("input", () => {
1026+
const isPreview =
1027+
descriptionEditorDialog.querySelector<HTMLInputElement>(
1028+
'input[name="description-editor-mode"]:checked',
1029+
)?.value === "preview";
1030+
descriptionEditorTextarea.hidden = isPreview;
1031+
descriptionEditorPreview.hidden = !isPreview;
1032+
if (isPreview) renderDescriptionPreview(descriptionEditorPreview, descriptionEditorTextarea.value);
1033+
});
1034+
1035+
el<HTMLButtonElement>("description-editor-done").addEventListener("click", () =>
1036+
descriptionEditorDialog.close(),
1037+
);
1038+
1039+
function mountDescriptionExpand(): void {
1040+
const toolbar = form.querySelector<HTMLElement>('[data-role="description-mode-toggle"]');
1041+
const textarea = form.querySelector<HTMLTextAreaElement>('[data-key="description"]');
1042+
const preview = form.querySelector<HTMLDivElement>(
1043+
'[data-role="description-inline-preview"]',
1044+
);
1045+
const expandBtn = form.querySelector<HTMLButtonElement>('[data-role="description-expand"]');
1046+
if (!toolbar || !textarea || !preview || !expandBtn) return;
1047+
1048+
toolbar.addEventListener("input", () => {
1049+
const isPreview =
1050+
toolbar.querySelector<HTMLInputElement>("input:checked")?.value === "preview";
1051+
textarea.hidden = isPreview;
1052+
preview.hidden = !isPreview;
1053+
if (isPreview) renderDescriptionPreview(preview, textarea.value);
1054+
});
1055+
1056+
expandBtn.addEventListener("click", () => {
1057+
const mode =
1058+
toolbar.querySelector<HTMLInputElement>("input:checked")?.value === "preview"
1059+
? "preview"
1060+
: "edit";
1061+
descriptionEditorTextarea.value = state.description;
1062+
descriptionEditorTextarea.hidden = mode === "preview";
1063+
descriptionEditorPreview.hidden = mode !== "preview";
1064+
if (mode === "preview") renderDescriptionPreview(descriptionEditorPreview, state.description);
1065+
const radio = descriptionEditorDialog.querySelector<HTMLInputElement>(
1066+
`input[name="description-editor-mode"][value="${mode}"]`,
1067+
);
1068+
if (radio) radio.checked = true;
1069+
descriptionEditorDialog.showModal();
1070+
});
1071+
}
1072+
9911073
/** One pill per rendered section — jumps to it, opening it first if collapsed. */
9921074
/** Wires a toggle button + dropdown panel: click toggles, outside click/Escape closes. Returns close(). */
9931075
function wireDropdown(
@@ -1049,6 +1131,16 @@ async function startEditor(repo: string | null): Promise<void> {
10491131
sectionNav.append(toggle, list);
10501132
}
10511133

1134+
/** Passed into renderForm as onSectionRebuilt: a chippable section (Where,
1135+
* What) rebuilding its own subtree — e.g. Description added via its "+"
1136+
* chip — doesn't go through render() below, so anything that mounts
1137+
* after the fact (the geo map, the description toolbar/expand wiring)
1138+
* has to be re-run from here too, not just at the bottom of render(). */
1139+
function onSectionRebuilt(): void {
1140+
mountMap();
1141+
mountDescriptionExpand();
1142+
}
1143+
10521144
function render(extra: ReadonlySet<string> = new Set()): void {
10531145
const rendered = renderForm(
10541146
form,
@@ -1058,7 +1150,7 @@ async function startEditor(repo: string | null): Promise<void> {
10581150
onInput,
10591151
onArrayInput,
10601152
onTranslationsCommit,
1061-
mountMap,
1153+
onSectionRebuilt,
10621154
onCustomizeRecurrenceRule,
10631155
);
10641156
refreshTranslations = rendered.refreshTranslations;
@@ -1067,6 +1159,7 @@ async function startEditor(repo: string | null): Promise<void> {
10671159
setAllDay(form, state.allDay);
10681160
markImportGaps(form, importMissing ?? new Set());
10691161
mountMap();
1162+
mountDescriptionExpand();
10701163
refresh();
10711164
}
10721165

0 commit comments

Comments
 (0)