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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/microsoft-whiteboard-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion site/export-microsoft-whiteboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ <h2 id="what-comes-across">What comes across</h2>
<li>Loop components as links to them, and Azure DevOps work items with their title, owner, and state.</li>
<li>Comments, as text beside the board with the names of their authors.</li>
</ul>
<p>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.</p>
<p>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.</p>
<p>SQLBI Whiteboard keeps each board in a file on your PC and has no real-time collaboration. <a href="compare.html">Compare</a> lists the differences from Microsoft Whiteboard.</p>
</article>

Expand Down
46 changes: 44 additions & 2 deletions src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>
/// The four reactions the web client offers on a note, by the name its pill carries.
/// </summary>
private static readonly Dictionary<string, string> ReactionSymbols = new(StringComparer.Ordinal)
{
["Like"] = "\U0001F44D",
["Heart"] = "\u2665",
["Laugh"] = "\U0001F602",
["Think"] = "\U0001F914",
};
private const string LoopTitle = "Loop";
private const string WorkItemTitle = "Work item";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -722,10 +738,36 @@ private static MicrosoftWhiteboardShape ReadNote(Anchor anchor, List<HtmlTag> ta
color,
color,
1,
ReadText(tags, from, to),
WithReactions(ReadText(tags, from, to), tags, from, to),
ReadFont(coreStyle, fontSize * anchor.Scale, defaultBold: false));
}

/// <summary>
/// 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 <c>id</c> is the reaction's name and its
/// <c>span.reactionPillText</c> the count.
/// </summary>
private static string WithReactions(string text, List<HtmlTag> tags, int from, int to)
{
var reactions = new List<string>();
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));
}

/// <summary>
/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ private sealed class FixedMeasure : IBoardTextMeasure
<div class="anchor align topLeft" data-whiteboard-type="Note" style="left: 1000px; top: 0px;">
<div class="textBoxBackground noteVisualUpdate softBlueGradient"><div class="textbox stickyNote" style="width: 304px; height: 265px; font-size: 32px;">
<div class="textBoxCore textArea" style="font-weight: 400;"><div data-block="true"><span data-text="true">First line</span></div><div data-block="true"><span data-text="true">Second &amp; last</span></div></div></div></div>
<div class="ReactionTagContainer"><button type="button" class="ms-Button ReactionPill"></button></div>
<div class="ReactionTagContainer"><div class="ReactionPillsContainer">
<div id="Heart" class="ReactionPillContainer"><button type="button" class="ms-Button ReactionPill" aria-label="One Heart reaction tag"><i class="icon reactionTagIcon"><svg width="20" height="20"></svg></i><span class="reactionPillText" dir="ltr">1</span></button></div>
<div id="Like" class="ReactionPillContainer"><button type="button" class="ms-Button ReactionPill"><i class="icon reactionTagIcon"><svg width="20" height="20"></svg></i><span class="reactionPillText" dir="ltr">3</span></button></div>
</div></div>
</div>
<div class="anchor align topLeft" data-whiteboard-type="PlainText" style="left: 0px; top: 600px;">
<div style="width: 400px; display: flex; justify-content: center;"><div class="textbox plainText" style="max-width: 400px; font-size: 20px;">
Expand Down Expand Up @@ -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",
Expand All @@ -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.");

Expand Down
Loading