> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# HTML Editors & Viewers — where rich text is written and shown

> Every rich-text editor and HTML viewer of the Flutter app (web and mobile): where each one is used, the package behind it, the format it saves, and how YouTube embeds play on mobile (the "Error 153" fix).

Written on 31/10/2026 from the code on the `chat+ai` branch of `LMS_Frontend` (up to Update 180).
Keywords for search: SunEditor, flutter\_quill, Quill Delta, Fleather, `HtmlWidget`,
`AppHtmlContent`, `RichNoteEditor`, YouTube, Error 153, WebView, `Referer`.

**In one sentence.** Lessons and classrooms are written in **HTML** (SunEditor on the web), every
other rich-text field is a **Quill Delta JSON** string written with `flutter_quill`, and every
authored HTML is shown through **`AppHtmlContent`**, the one viewer that knows how to play YouTube
and Google Drive embeds on mobile.

## At a glance

| Content | Stored as | Edited with (web) | Edited with (native apps) | Shown with |
| :- | :- | :- | :- | :- |
| Lesson content | HTML (`lesson.content`) | **SunEditor 3.4** | `flutter_quill` + the app's HTML codec | `AppHtmlContent` |
| Classroom content | HTML (`classroom.content`) | **SunEditor 3.4** | `flutter_quill` + the app's HTML codec | `AppHtmlContent` |
| Question explanation | Quill Delta JSON (`question.explanation`) | `flutter_quill` | `flutter_quill` | `QuestionExplanationView` (web), a read-only `QuillEditor` (phones) |
| Instructor education / work description | Quill Delta JSON (background item `description`) | `flutter_quill` (`RichNoteEditor`) | same | `RichNoteView` |
| Order notes (admins only) | Quill Delta JSON (`order.notes`) | `flutter_quill` (`RichNoteEditor`) | same | plain-text preview in the orders table |

> [!NOTE]
>
> **"Native apps" means tablets in practice.** Lessons, classrooms, explanations, instructor profiles
> and order notes are all edited by **staff** (instructor, content manager, admin). The app signs every
> staff role out on a **phone** ("this role is not supported on this device"), so the native editors
> only ever run on an **Android tablet or an iPad**, where a staff user can sign in. Students on phones
> only see the **viewers**.

***

## 1. Editors

### 1.1 Lesson and classroom content: `HtmlContentEditor`

One widget, two engines, chosen by platform. Code: `lib/ui/widgets/rich_text_editor/`.

> [tab] 🌐 Web: SunEditor 3.4
>
> * **SunEditor 3.4** (MIT), **self-hosted** in `web/suneditor/` (no CDN): `suneditor.min.js` /
>   `.css`, `lms_editor.html` (the page), `lms_editor.js` (the config, the app's buttons and the
>   bridge), `lms_editor.css`, `suneditor-dark.css`, and `langs/ar.js` (our own Arabic file:
>   SunEditor ships none).
> * Loaded in an `<iframe>` by `SunEditorFrame` (`sun_editor_frame_web.dart`, a Flutter
>   `HtmlElementView`). The page is same-origin, so the app reads the HTML **synchronously**
>   (`window.LmsEditor.getHtml()`) and the save paths stay synchronous.
> * Toolbar: styles, colors, lists, tables, links, image / video / iframe embeds, HTML code view,
>   plus the app's **upload a PDF** button and the host's own buttons (the classroom's Google
>   Calendar).
> * **Every file goes to Cloudflare R2**, never base64: images (dialog, paste, drop) and PDFs are
>   sent to Flutter, which uploads them with `POST /upload_file` (type `3`), naming the host's
>   training (and saved lesson) or classroom by id; the backend puts the file in that owner's folder
>   (`files/all-lessons/{training-slug}` or `files/all-classrooms/{slug}`, see
>   [R2 Storage Paths](https://theteam-13fc7f49.mintlify.site/api-reference/r2-storage-paths.md)), then the app inserts the `<img>` or the PDF
>   `<iframe>`. Other file types are refused.
> * While a dialog covers the editor, the iframe gets `pointer-events: none`, or it would take the
>   dialog's clicks.
> * In use since Update 153 (14/10/2026). To upgrade SunEditor, follow the comment at the top of
>   `lms_editor.html` and bump `kSunEditorVersion` (`3.4.0-1` today).

> [tab] 📱 Native: flutter\_quill
>
> * `flutter_quill` with the app's toolbar: text styles, colors, lists, alignment, direction,
>   links, quotes, code, **insert a table**, **embed content** (an `<iframe>` code or a YouTube /
>   Drive / any link), the host's buttons, and an **HTML source** view (saved as typed).
> * HTML ↔ Quill goes through `HtmlQuillCodec`, the app's own converter (`html` package parser).
>   `flutter_quill_delta_from_html` is **not** used: it merges consecutive `<p>` into one line and
>   drops iframes and tables.
> * Iframes, tables and any HTML the editor has no tool for (`<video>`, `<hr>`, `<form>`, …) become
>   **cards** (`HtmlEmbedBuilder`: Preview / Edit / Remove) that keep the original HTML and write
>   it back unchanged.
> * Uploading an image or a PDF is web only (the button explains it).
> * In use since Update 113 (14/10/2026), replacing `html_editor_enhanced` (Summernote in an
>   iframe / WebView).

**Shared rules**

* **Unedited content comes back byte for byte.** `HtmlContentEditorController.html` returns the
  loaded string until the user edits something, so opening and saving never rewrites a lesson.
* Hosts: the lesson editor `add_lessons_content.dart` (admin, instructor, content manager) and the
  classroom editor `classroom_content_widget.dart`.
* Guarded by `test/html_content_editor_test.dart` and `test/html_quill_codec_test.dart`.

### 1.2 Question explanations: `QuestionExplanationDialog`

* `lib/ui/widgets/quiz/question/question_explanation_dialog.dart`, opened from the quiz / exam
  editor's questions step (`questions_create_widget.dart`).
* `flutter_quill` + `flutter_quill_extensions` (embed builders). Saved in `question.explanation` as
  a Quill Delta JSON string, for example `[{"insert":"Because "},{"insert":"PMP","attributes":{"bold":true}},{"insert":" is right.\n"}]`.
* The video / image toolbar buttons are off, so a Quill explanation never carries a video. An
  explanation can still be **HTML** (imported quizzes, older rows): see the viewer in §2.2.

### 1.3 Instructor descriptions and order notes: `RichNoteEditor`

Since **Update 180 (31/10/2026)**. These two fields used **Fleather**, a fork of Quill; the app now
uses `flutter_quill` for them too, and `fleather` is no longer a dependency.

* Code: `lib/ui/widgets/rich_text_editor/rich_note_editor.dart` (`RichNoteEditor`, `RichNoteView`)
  and `rich_note_document.dart` (`RichNoteDocument.parse` / `encode` / `plainText`).

* Hosts:

  | Screen | File | Field |
  | :- | :- | :- |
  | Instructor profile → add / edit an education or a work experience (dialog) | `instructor_edit_profile_dialog.dart` | `description` of the background item |
  | Admin → Orders → **Add note** / **Edit notes** (dialog) | `edit_notes_dialog_widget.dart` | `notes` of the order (private: buyers never see it) |

* Toolbar (one row in the dialogs): undo / redo, bold, italic, underline, strikethrough, text color,
  background color, clear format, left / center / right alignment, text direction, heading, numbered
  and bulleted lists, indent, link.

* Saved as Quill Delta JSON, like question explanations.

> [accordion] Rows saved by Fleather are still read (how the conversion works)
>
> Fleather saved a Delta too, but with its own attribute names. `RichNoteDocument.parse` converts
> them **when the row is loaded**; the row is stored in the Quill format the next time someone saves
> it. Nothing is migrated on the server.
>
> | Fleather (saved before 31/10/2026) | Quill (saved now) |
> | :- | :- |
> | `b`, `i`, `u`, `s`, `c` | `bold`, `italic`, `underline`, `strike`, `code` |
> | `a` (link) | `link` |
> | `fg` / `bg`: an ARGB integer (`4293212469`) | `color` / `background`: `#AARRGGBB` (`#ffe53935`); fully transparent is dropped |
> | `heading: 2` | `header: 2` |
> | `block: ul` / `ol` | `list: bullet` / `ordered` |
> | `block: cl` + `checked` | `list: checked` / `unchecked` |
> | `block: quote` / `code` | `blockquote` / `code-block` |
> | `alignment` | `align` |
> | `direction`, `indent` | unchanged |
> | the horizontal-rule embed `{"_type":"hr"}` | a line of dashes `———` |
> | plain text (older rows, not JSON) | the same text, one paragraph per line |
>
> Guarded by `test/rich_note_editor_test.dart`.

***

## 2. Viewers

### 2.1 `AppHtmlContent`: the one viewer for authored HTML

`lib/ui/widgets/shared/app_html_content.dart` (since Update 60). It is `HtmlWidget` from
**`flutter_widget_from_html`** plus the rules for the embeds the stock renderer gets wrong, in one
place (`AppHtmlContent.embedFor`):

| `<iframe src>` | Rendered with | Why |
| :- | :- | :- |
| YouTube (`/embed/…`, `youtu.be`, `watch?v=`, `youtube-nocookie.com`, `//…`, `/live/…`) | `AppYoutubeEmbed`, **native apps only**; the web keeps the browser's iframe | see [§4](#4-youtube-embeds-and-error-153) |
| Google Drive file (`drive.google.com/file/d/…`) | `AppDrivePdfEmbed`: the app's PDF viewer (`syncfusion_flutter_pdfviewer`), 70 % of the screen tall | the PDF opens in the app instead of Drive's preview page |
| anything else | the stock renderer | |

It also removes the 40px indent of SunEditor's `<figure>` wrappers (images, videos, tables).

| Where | File | Platforms |
| :- | :- | :- |
| Course player, lesson body | `preview_lessons_and_quiz.dart` | web + phones |
| Classroom content | `classroom_content_preview.dart` | web + phones, all four roles |
| Lesson details (staff) | `lesson_details.dart` (`drivePdfViewer: false`) | web + tablets |
| Training editor, lesson preview | `add_trainings_page_two.dart` | web + tablets |
| HTML editor, embed preview dialog | `html_editor_dialogs.dart` (`drivePdfViewer: false`) | tablets |
| Question explanations written in HTML | `question_explanation_view.dart` (since Update 180) | web + tablets |

Never give `AppHtmlContent` (or the `HtmlWidget` inside) a `UniqueKey`. Every rebuild would parse
the HTML again and recreate the embeds, and a rotation is a rebuild: a video would restart while
going full screen. Key it by the content's identity (the course player uses the lesson id).

### 2.2 Question explanations: `QuestionExplanationView`

`lib/ui/widgets/quiz/question/question_explanation_view.dart`, used by the exam and training-quiz
**review "tips"** cards on the web (and tablets).

| Stored explanation | Shown with |
| :- | :- |
| Quill Delta JSON (a list, or `{"ops": [...]}`) | a read-only `QuillEditor` |
| HTML | `AppHtmlContent` (since Update 180; before: a bare `HtmlWidget`) |
| plain text | the text |
| empty | the "No explanation was provided" state |

The two **phone** review screens (`quiz_review_question_tips_mobile.dart`,
`training_quiz_review_question_tips_mobile_widget.dart`) and the staff exam answer-key preview
(`admin_exam_preview_answers_widget.dart`) build their own read-only `QuillEditor` instead. A Quill
explanation looks the same there; an **HTML** explanation shows as raw markup on phones (known
issue 12, see [§4.4](#44-what-is-left)).

### 2.3 Instructor descriptions and order notes

* `RichNoteView` draws a description read-only (nothing for an empty one). It is used by
  `instructor_profile_item.dart`, so every list of educations / work experiences shows it: the
  instructor's own profile pages and the public instructor profile students open (web and phone).
* The orders table shows notes as **plain text** (`RichNoteDocument.plainText`, two lines with a
  tooltip): no editor per row. The same helper finds the Konnect payment link in the notes.

### 2.4 Other HTML on screen

| What | How | Note |
| :- | :- | :- |
| Training preview page, lesson preview dialog (`_openLessonPreview` in `preview_training.dart`) | its own `HtmlWidget` + rules: YouTube → `WebYouTubePlayer`, Drive PDF → `SfPdfViewer` at 60 % | plays fine on mobile, but duplicates the `AppHtmlContent` rules (known issue 12) |
| Quill editor cards (iframe / table / HTML) | `HtmlWidget` thumbnail inside the card (`html_embed_builders.dart`) | not interactive (`IgnorePointer`) |
| Privacy policy | `webview_flutter` loading `assets/privacy_policy_{en,fr,ar}.html` (`web_view_privacy_policy.dart`) | static asset |
| Training preview hero / catalog video | `WebYouTubePlayer`: `youtube_player_iframe` on the web, `AppYoutubeEmbed` on mobile | |

***

## 3. Packages

| Package | Version (`pubspec.yaml`) | Used for |
| :- | :- | :- |
| SunEditor (JavaScript, not a Dart package) | 3.4.0, self-hosted in `web/suneditor/` | the web lesson / classroom editor |
| `flutter_quill` | `^11.6.0` | every Dart editor and read-only Quill view |
| `flutter_quill_extensions` | `^11.0.0` | Quill image / video embed builders |
| `html` | `^0.15.6` | parsing HTML in `HtmlQuillCodec` |
| `flutter_widget_from_html` | `^0.17.4` | `HtmlWidget`, under `AppHtmlContent` |
| `webview_flutter` (+ `webview_flutter_android` `^4.14.1`, `webview_flutter_wkwebview` `^3.26.2`) | `^4.14.1` | `AppYoutubeEmbed` on mobile, the privacy policy |
| `youtube_player_iframe` | `^6.0.2` | `WebYouTubePlayer` on the **web** only |
| `syncfusion_flutter_pdfviewer` | `^35.1.39` | Google Drive PDFs in lessons / classrooms |
| ~~`fleather`~~ | removed (Update 180) | was: instructor descriptions, order notes |
| ~~`html_editor_enhanced`~~ | removed (Update 113) | was: Summernote lesson / classroom editor |

***

## 4. YouTube embeds and "Error 153"

### 4.1 The problem

On the **mobile app**, a YouTube video placed in a lesson as an `<iframe>` showed
**"Video player configuration error – Error 153"** instead of playing. The web app was fine.

**Cause.** YouTube now requires every embedded player to identify the app or site that embeds it,
through the HTTP `Referer` header ("embedded player client identity"). The browser sends it on the
web. On mobile:

* `HtmlWidget` renders an `<iframe>` in a **WebView** that opens `youtube.com/embed/<id>` directly,
  with **no `Referer`** → Error 153;
* `youtube_player_iframe` loads its player with `https://www.youtube.com` as base URL → Error 152.

Measured with the same video:

| `Referer` sent | Result |
| :- | :- |
| none | ❌ Error 153 |
| `https://www.youtube.com/` | ❌ Error 152 |
| `https://com.example.strived/` (the app ID, YouTube's documented method for WebViews) | ✅ plays |

### 4.2 The fix: `AppYoutubeEmbed`

`lib/ui/widgets/shared/video_player/app_youtube_embed.dart` (Update 50, 02/10/2026):

* loads YouTube's standard embed in a WebView page whose **base URL is the app ID**,
  `kYoutubeEmbedReferrer = 'https://com.example.strived'`, so YouTube receives a valid `Referer`;
* 16:9 frame with YouTube's own controls; keeps the `start` / `t` time of a pasted iframe;
* full screen goes **landscape** on Android (Back or YouTube's exit button returns to portrait);
  iOS plays inline (`allowsInlineMediaPlayback`) and uses its native full screen;
* "Watch on YouTube" and other links open in the YouTube app or the browser;
* `setTextZoom(100)` on Android, so the system font size does not distort the player.

`mobileYoutubeIframeEmbed(src)` returns that player for a YouTube `<iframe>` on any native device
(phone or tablet) and `null` on the web, where the browser's iframe already works.
`WebYouTubePlayer` uses `AppYoutubeEmbed` on mobile and `youtube_player_iframe` on the web.

`kYoutubeEmbedReferrer` must stay equal to the Android `applicationId` / iOS bundle ID. The iOS
bundle ID is still the `com.example.strived` placeholder: if it changes, change the constant too, or
every mobile video goes back to Error 153.

### 4.3 How the fix reached every screen

**1. Update 50 · 02/10/2026 — lessons**

The course player and the lesson details screen route YouTube iframes to `AppYoutubeEmbed`.
`WebYouTubePlayer` switches to it on mobile, which also fixes the training preview page videos.

**2. Update 60 · 03/10/2026 — classrooms, one renderer**

Classroom content still showed Error 153: its viewer had its own copy of the rules, without
YouTube. Instead of a third copy, the rules moved into **`AppHtmlContent`**, and the classroom
viewer, the course player and lesson details all use it. Any later fix to `embedFor` reaches
every viewer at once.

**3. Update 180 · 31/10/2026 — question explanations**

Known issue 12: an HTML explanation was still drawn by a bare `HtmlWidget`, so a YouTube iframe
in it would show Error 153 in the native app. `QuestionExplanationView` now uses
`AppHtmlContent` for HTML. Guarded by a YouTube case in `test/question_explanation_view_test.dart`.

### 4.4 What is left

Known issue 12 (reviewed 31/10/2026):

* the training preview page's **lesson preview dialog** works (it plays through `WebYouTubePlayer`)
  but keeps its own copy of the embed rules (Drive PDF at 60 % instead of 70 %): move it to
  `AppHtmlContent`;
* the two **phone review screens** draw explanations with their own `QuillEditor`, so an HTML
  explanation shows as markup there: use `QuestionExplanationView`.

***

## 5. Rules for new code

* Show authored HTML with **`AppHtmlContent`**, never a bare `HtmlWidget`, and add any new embed fix
  to `AppHtmlContent.embedFor` so every screen gets it.
* Short rich-text fields: use `RichNoteEditor` / `RichNoteView` and save with
  `RichNoteDocument.encode`. Read with `RichNoteDocument.parse`, which accepts Quill, Fleather and
  plain text. Don't add Fleather back.
* Import `flutter_widget_from_html` with `show HtmlWidget` next to `flutter_quill`: both export a
  `DefaultStyles`.
* Don't swap `HtmlQuillCodec` for `flutter_quill_delta_from_html`: it loses iframes, tables and
  paragraph breaks.
* Lesson and classroom HTML that was only opened must be saved unchanged (the controller returns
  the loaded string until an edit).

| Test | Guards |
| :- | :- |
| `test/app_html_content_test.dart` | the embed rules, SunEditor figures, no restart on rebuild |
| `test/html_content_editor_test.dart`, `test/html_quill_codec_test.dart` | the lesson / classroom editor and its HTML codec |
| `test/question_explanation_view_test.dart` | Delta / HTML / plain-text explanations, the YouTube case |
| `test/rich_note_editor_test.dart` | the Fleather conversion, `RichNoteEditor`, `RichNoteView` |

***

## 6. Writing HTML that looks the same in the editor and on the learners' page

Found on 05/11/2026 while building the content of the TEST classroom `class - 10` (a Google Meet
card, a weekly plan table, a callout) with SunEditor 3.4 and `flutter_widget_from_html` 0.17.4. The
card's HTML, ready to paste in the code view, is in the admin and instructor guides on the user guide site
([Classrooms and Chatrooms → The content editor](https://docs-strived.mintlify.site/admin-guide/classrooms-and-chatrooms#the-content-editor)).

**What SunEditor keeps** (strict mode, the default `tagStyles` plus the iframe / image additions of
`lms_editor.js`):

| Element | Styles kept |
| :- | :- |
| `span`, `strong`, `em`, `li`… | `color`, `background-color`, `font-family`, `font-size` |
| `p`, `h1`–`h6`, `blockquote`… | `text-align`, `margin`, `line-height` |
| `table`, `th`, `td` | `border` and every `border-*` (`border-collapse`, `border-radius`…), `color`, `background-color`, `text-align`, `font-weight`, `text-decoration`, `vertical-align` |
| `img`, `video`, `iframe` | sizes, `float`, `margin`, `border`, `transform` |
| `a`, `div` | nothing |

`padding` is dropped everywhere (except on SunEditor's own `<figure>`), so a styled `<div>`, a
padded box or a button-styled link loses its look as soon as the editor loads it.

**What the viewer does with it**

* A table's `width` moves to SunEditor's `<figure>` wrapper, and cell widths move to
  `<col style="96px">` (SunEditor 3.4 writes them without the property name). The viewer ignores
  both: **a table is as wide as its content**, up to the column. It fills the column only when its
  text needs the room (a two-line description does it).
* Tables get a 2px `border-spacing` unless they have `border-collapse: collapse`. With `collapse`, a
  cell's wider border wins over the table's own border. SunEditor drops `border-collapse: separate`
  on the first load and `border-spacing` on the second, so don't rely on either.
* An image inside SunEditor's image component ignores its `margin` (`AppHtmlContent.editorFigureStyles`
  sets the figure's margin to 0), and an image in a cell with a `border-top` is squashed. Space an
  image with **spacer cells** (`<td style="border: 0"><p style="margin: 0 10px">&nbsp;</p></td>`) and
  a spacer line above it (`<p style="margin: 0; line-height: 18px"><br></p>`). Cell borders in the
  background color work in the viewer but break the card's outline in the editor.
* Links are always underlined; a `<span style="color: …">` inside the link sets their color.
* A nested one-cell table with a `background-color` is a button (SunEditor keeps nested tables, in
  their own `<figure>`).

**How to check a design.** Load the HTML in `web/suneditor/lms_editor.html` (`LmsEditor.start`,
then `LmsEditor.getHtml()`), feed the result back once more, and save the **second** result: that is
what a later edit in the editor would save, so the page stays the same after someone opens it. Then
look at it on **⋯ → Preview**. The classroom endpoint `PUT /classroom/update/{id}` with only
`content` changes nothing else, which makes trying several versions quick on a test platform.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.