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.");