From 83a64caa5fd0c6b10284c47f275eb8856fc0534c Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 19:34:08 +0200 Subject: [PATCH] Keep reactions on sticky notes from Microsoft Whiteboard A note's reactions become a last line of its text, with each reaction's symbol and count, so they stay with the note. Reactions on anything other than a note are still counted. The heart is the suit symbol, because WPF draws the emoji heart hatched. Checked on the test board, whose one reaction was the last thing any sample reported as left out. It joins the 1.7.4 notes, which is not released yet. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ docs/microsoft-whiteboard-import.md | 7 +-- site/export-microsoft-whiteboard.html | 2 +- .../Import/MicrosoftWhiteboardExport.cs | 46 ++++++++++++++++++- .../MicrosoftWhiteboardSmokeTests.cs | 13 ++++-- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43159bf..7853ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ panel of its size, an Azure DevOps work item as a text container with its title, and state, a frame Copilot drew as a panel with its title, and a custom object as a gray box that names it. They used to be listed as left out. +### Reactions on sticky notes from Microsoft Whiteboard +Reactions on a sticky note now come across as a last line in the note, with each +reaction's symbol and count. + ## 1.7.3 - 30 September 2026 ### Microsoft Whiteboard imports closer to the original diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index 886c349..28f8558 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -62,7 +62,7 @@ no model of the board beyond what it draws. | `Shape` | An SVG whose `g` has `fill`, `stroke`, and `stroke-width` (in `pt`), and whose `path` draws the outline around the middle of the box. The shape's text sits in `div.textBoxContainer`. | | `LegacyEllipse` | A shape from an older version of the app: an SVG `ellipse` with the outline in its `stroke` attribute and the width and fill in its style. The radii can differ from the SVG's box, in proportion as well as in size, and the page draws the radii. | | `LegacyPolygon` | A shape from an older version of the app: an SVG `polygon` whose points start at the anchor, styled as a `LegacyEllipse` is. Every sample is a rectangle. | -| `Note` | A sticky note: `div.textBoxBackground` with a color class such as `softBlueGradient`, a 40-pixel author bar, and `div.stickyNote` with the text area's size and font size. | +| `Note` | A sticky note: `div.textBoxBackground` with a color class such as `softBlueGradient`, a 40-pixel author bar, and `div.stickyNote` with the text area's size and font size. Its reactions are pills in `div.ReactionTagContainer`: one `div.ReactionPillContainer` per reaction, whose `id` is `Like`, `Heart`, `Laugh`, or `Think`, with the count in `span.reactionPillText`. | | `GridList` | A note grid: a title editor, then `div.listChild` notes in a CSS grid of `repeat(n, auto)` columns. | | `VerticalList`, `VerticalBulletList`, `VerticalCheckboxList`, `UnknownList` | A list of text, bullets, or tasks, in `div.legacyListContainer`: a title editor in `div.verticalListTitle`, a `div.listColumnHeading` with the column names, then one `div.listChild` per item with its icon, its text editor, who a task is assigned to (`div.assignedUserDisplayName`), and its likes. A done task's icon is `CheckmarkCircle` with the class `checkedListItemIcon`, an open one `StatusCircle` with `uncheckedListItemIcon`. The list is 44 + 284 + 76 pixels wide, or 44 + 364 + 76 when tasks have an Assigned to column. An `UnknownList` draws no items. Not seen in a sample; read from the web client's `ListContainerComponent`. | | `PlainText` | A text box: `div.textbox.plainText` with `max-width` and `font-size`, inside a wrapper whose `justify-content` centers the text in a wider box when the text is centered. | @@ -129,7 +129,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | Shape | `ShapeBoardObject` of the kind its outline has, with fill, outline, angle, and text | | Older ellipse or polygon | `ShapeBoardObject`, an oval with the ellipse's radii or the kind the polygon's corners have, without text | | Rectangle, in either form | `ShapeBoardObject`, `ShapeKind.Rectangle`, with square corners | -| Sticky note | `ShapeBoardObject`, rectangle in the note's color, with the note's text | +| Sticky note | `ShapeBoardObject`, rectangle in the note's color, with the note's text and, on a last line, its reactions as 👍, ♥, 😂, or 🤔 with their counts. The heart is the suit symbol, because WPF draws the emoji heart hatched | | Note grid | A white rectangle for the panel, a label for the title, and one note per cell | | Text box | `FreeTextBoardObject`, wrapped into the lines the box showed | | Connector | `ConnectorBoardObject`, straight, with its ends bound to shapes they sit on | @@ -225,7 +225,8 @@ parses in about 160 ms. - **Elbow connectors are straight.** Their ends stay where they were and stay bound. - **A turned picture cannot be turned back**, because it is an SVG of the turned picture. - **A link card loses its preview picture.** The link and its description are kept. -- **Reactions on notes** are counted and left out. The board has no reactions. +- **Reactions** are a line of text in the note, not pills, and who reacted is left out. + Reactions on anything other than a note are counted and left out. - **Comments** become text containers beside the board, not pins on their objects. Authors keep their names; their email addresses are left out. - **Author names on notes, locks, and alt text** are left out, because the board has no diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index 1ea3660..8bb9f47 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -228,7 +228,7 @@

What comes across

  • Loop components as links to them, and Azure DevOps work items with their title, owner, and state.
  • Comments, as text beside the board with the names of their authors.
  • -

    A highlighter stroke has one width, and Rainbow and Galaxy ink become one color. Elbow connectors become straight. The background color and reactions are left out. After the import, a message lists anything the board did not bring across.

    +

    A highlighter stroke has one width, and Rainbow and Galaxy ink become one color. Elbow connectors become straight, and reactions on notes become a line of text in the note. The background color is left out. After the import, a message lists anything the board did not bring across.

    SQLBI Whiteboard keeps each board in a file on your PC and has no real-time collaboration. Compare lists the differences from Microsoft Whiteboard.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index ad80c8e..b6ede55 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -28,6 +28,17 @@ public sealed partial class MicrosoftWhiteboardExport private const string CommentsSuffix = "-comments.json"; private const string CommentTitle = "Comment"; private const string ListTitle = "List"; + + /// + /// The four reactions the web client offers on a note, by the name its pill carries. + /// + private static readonly Dictionary ReactionSymbols = new(StringComparer.Ordinal) + { + ["Like"] = "\U0001F44D", + ["Heart"] = "\u2665", + ["Laugh"] = "\U0001F602", + ["Think"] = "\U0001F914", + }; private const string LoopTitle = "Loop"; private const string WorkItemTitle = "Work item"; @@ -386,7 +397,12 @@ void Skip(string what, int count = 1) break; } - Skip("Reactions on notes", tags.Count(tag => tag.Name == "button" && tag.HasClass("ReactionPill"))); + // A note keeps its reactions in its text, and so does a note in a grid or a + // template. Reactions on anything else are counted. + if (anchor.Type is not ("Note" or "GridList" or "LegacyTemplate")) + { + Skip("Reactions", tags.Count(tag => tag.Name == "div" && tag.HasClass("ReactionPillContainer"))); + } } return new MicrosoftWhiteboardExport(name, items, skipped); @@ -722,10 +738,36 @@ private static MicrosoftWhiteboardShape ReadNote(Anchor anchor, List ta color, color, 1, - ReadText(tags, from, to), + WithReactions(ReadText(tags, from, to), tags, from, to), ReadFont(coreStyle, fontSize * anchor.Scale, defaultBold: false)); } + /// + /// The reactions on a note as a last line of its text, one symbol and count each, in + /// the order the note shows them. A pill's id is the reaction's name and its + /// span.reactionPillText the count. + /// + private static string WithReactions(string text, List tags, int from, int to) + { + var reactions = new List(); + for (var index = from; index < to; index++) + { + if (tags[index].Name != "div" || !tags[index].HasClass("ReactionPillContainer")) + { + continue; + } + + var name = tags[index].Attribute("id") ?? string.Empty; + var count = tags.Skip(index).Take(to - index) + .FirstOrDefault(tag => tag.HasClass("reactionPillText"))?.Text.Trim(); + reactions.Add($"{(ReactionSymbols.TryGetValue(name, out var symbol) ? symbol : name)} {count}".Trim()); + } + + return reactions.Count == 0 + ? text + : string.Join('\n', new[] { text, string.Join(" ", reactions) }.Where(line => line.Length > 0)); + } + /// /// A note grid is a white panel with a title, holding its notes in rows. It /// becomes a shape for the panel, a label for the title, and one shape per note. diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index dc774f0..2d6d641 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -187,7 +187,10 @@ private sealed class FixedMeasure : IBoardTextMeasure
    First line
    Second & last
    -
    +
    +
    +
    +
    @@ -231,8 +234,8 @@ private sealed class FixedMeasure : IBoardTextMeasure private static void RunObjects() { var board = MicrosoftWhiteboardExport.Parse(ObjectsPage, "Objects", ObjectsComments); - Assert(board.Skipped.Count == 2 && board.Skipped["Timer"] == 1 && board.Skipped["Reactions on notes"] == 1, - "An unknown object is counted by its type, and reactions on notes by what they are."); + Assert(board.Skipped.Count == 1 && board.Skipped["Timer"] == 1, + "An unknown object is counted by its type, and a note's reactions are not left out."); var arrow = (MicrosoftWhiteboardShape)board.Items[0]; Assert(arrow.Kind == ShapeKind.BlockArrow && arrow.Text == "Arrow", @@ -250,8 +253,8 @@ private static void RunObjects() "No fill and a transparent outline stay without paint."); var note = (MicrosoftWhiteboardShape)board.Items[3]; - Assert(note.FillArgb == 0xFF99C9EF && note.Text == "First line\nSecond & last" && !note.Font.Bold, - "A note is a shape in its color, one line per block, entities decoded."); + Assert(note.FillArgb == 0xFF99C9EF && note.Text == "First line\nSecond & last\n\u2665 1 \U0001F44D 3" && !note.Font.Bold, + "A note is a shape in its color, one line per block, entities decoded, and its reactions on a last line."); Assert(note.Center == new PointD(1152, 152.5) && note.Width == 304 && note.Height == 305, "A note covers its text and the author bar above it.");