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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ box that names it. They used to be listed as left out.
Reactions on a sticky note now come across as a last line in the note, with each
reaction's symbol and count.

### Placeholders from Microsoft Whiteboard
A table, template, or other object that Microsoft Whiteboard exported as a placeholder now
comes across as a box with the placeholder's message, where it used to be listed as left
out. The export does not hold what the placeholder stands for.

## 1.7.3 - 30 September 2026

### Microsoft Whiteboard imports closer to the original
Expand Down
10 changes: 7 additions & 3 deletions docs/microsoft-whiteboard-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ no model of the board beyond what it draws.
| `WorkItem` | An Azure DevOps work item: a `div.WorkItem` card, 220 × 145 pixels in the stylesheet, white with a 4-pixel gold border on its left. A `span.WorkItemTextField` holds a `strong` with the type's `img.WorkItemIcon` (its `alt` names the type) and the ID, followed by the title; then come who it is assigned to and, in `span.WorkItemText`, the word *State* in the board's language and the state. The web client draws a placeholder instead when its `EnableWorkItems` flag is off. Not seen in a sample; read from the web client's `WorkItemConnected`. |
| `Frame` | A frame Copilot drew around objects it grouped: a `section.whiteboardFrame` with its `width` and `height` and a theme class, `whiteboardFrame--blue`, `--green`, `--purple`, `--orange`, or neutral without one. The stylesheet gives it a 3-pixel border, a translucent fill, 14-pixel corners, and a 44-pixel title bar with bold 15-pixel text in `span.whiteboardFrame__titleText`, padded 14 pixels in. A pill names the first source and counts the rest; the full list is in a popover that is drawn only when open. The web client draws nothing when its `EnableFrames` flag is off. Not seen in a sample; read from the web client's frame component. |
| `CustomElement` | A widget from a plug-in, named by its kind in `data-custom-widget-kind`. The web client registers only two examples, `example.counter` and `example.embed`; for anything else, and for every kind when its `EnableCustomElement` flag is off, it draws a gray box that fills the object and says the widget is not available or disabled. The box is at least 120 × 80 pixels, and its own size is `100%`, so the object's size is the first one given in pixels. Not seen in a sample; read from the web client's custom widget component. |
| Any type drawn as a placeholder | When a feature is off for whoever exports the board, the web client draws a `div.unknownObject` in place of a table, a template, a work item, or another kind: a title and a description in the board's language, in `div.unknownObjectText`, and nothing of the content. The stylesheet makes it at least 448 × 170 pixels, white, with a shadow and no border, and the title 20 pixels. Not seen in a sample; read from the web client's placeholder component and stylesheet. |
| `CommentThread` | A pin, described above. |

### Ink
Expand Down Expand Up @@ -141,6 +142,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C
| Work item | Markdown `TextBoardObject` titled *Work item*: the type and ID in bold before the title, then who it is assigned to and its state, as wide as the card |
| Copilot frame | `ShapeBoardObject` rectangle with the theme's border and translucent fill, and its title as a bold label in the theme's text color. Its sources are left out |
| Custom object | `ShapeBoardObject` rectangle of its size in the gray the web client draws, with *Custom object* and the kind as its text |
| Placeholder | `ShapeBoardObject` white rectangle of the placeholder's size with a light outline, because a shadow does not carry over, and its two lines as its text. A template whose children were drawn is read child by child, and a child can be a placeholder |
| Link card | Markdown `TextBoardObject` titled *Link* |
| List | Markdown `TextBoardObject` titled *List*: the list's title in bold, then a Markdown list of bullets, or one line per item with ☒ for a done task and ☐ for an open one |
| Comment thread | Markdown `TextBoardObject` titled *Comment*, in a column to the right of the board |
Expand Down Expand Up @@ -225,6 +227,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.
- **Content drawn as a placeholder** stays a placeholder. The export holds the box and its
message, not the table or template it stands for.
- **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.
Expand All @@ -251,9 +255,9 @@ parses in about 160 ms.

The web client's code names every type an export can hold. Its `data-whiteboard-type`
values, as of version 26.10910.101 in September 2026, are the types above and these,
which the importer reports and leaves out: an `Unknown` that holds no table, live
content without a Loop element, an app frame without its box, a work item or a frame
drawn as a placeholder, and a custom object without a kind.
which the importer reports and leaves out: an `Unknown` that holds neither a table nor a
placeholder, live content without a Loop element, an app frame without its box, a work
item or a frame drawn as nothing at all, and a custom object without a kind.

The tokenizer yields text that follows an end tag as a tag named `#text`, because a work
item's title comes after the bold ID.
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, 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>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, and an object the export shows as a placeholder stays one. 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
57 changes: 57 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public sealed partial class MicrosoftWhiteboardExport
private const uint CustomElementTextArgb = 0xFF605E5C;
private static readonly PointD CustomElementMinimum = new(120, 80);

/// <summary>
/// The smallest box the web client's stylesheet gives content it cannot show.
/// </summary>
private static readonly PointD PlaceholderSize = new(448, 170);

/// <summary>
/// A work item card in the web client's stylesheet.
/// </summary>
Expand Down Expand Up @@ -241,6 +246,15 @@ void Skip(string what, int count = 1)

foreach (var (anchor, tags) in SplitAnchors(HtmlTags.StartTags(html, start)))
{
// What the web client could not draw, because a feature was off for whoever
// exported the board, is a placeholder box that says so. The box is what the
// page shows, so it is what comes across.
if (ReadPlaceholder(anchor, tags) is { } placeholder)
{
items.Add(placeholder);
continue;
}

switch (anchor.Type)
{
case "InkGroup":
Expand Down Expand Up @@ -1151,6 +1165,12 @@ private static IEnumerable<MicrosoftWhiteboardItem> ReadTemplateChild(Anchor pla
bool Has(Func<HtmlTag, bool> test) => content.Any(test);
HtmlTag? First(Func<HtmlTag, bool> test) => content.FirstOrDefault(test);

if (ReadPlaceholder(place, content) is { } placeholder)
{
yield return placeholder;
yield break;
}

if (Has(tag => tag.HasClass("templateTitle")))
{
var title = ReadText(content, 0, content.Count);
Expand Down Expand Up @@ -1470,6 +1490,43 @@ private static IEnumerable<MicrosoftWhiteboardItem> ReadFrame(Anchor anchor, Lis
? StyleLength(style, property)
: null;

/// <summary>
/// The box the web client draws for content it cannot show: a table or a template
/// when those features are off, and the same for work items and other kinds. It
/// holds a title and a description in the board's language and nothing of the
/// content, and the stylesheet makes it at least 448 by 170 pixels, white, with a
/// shadow. It becomes a white rectangle of that size with a light outline, because
/// a shadow does not carry over, and the two lines as its text. A template whose
/// children were drawn is read child by child, and a child can be a placeholder.
/// </summary>
private static MicrosoftWhiteboardShape? ReadPlaceholder(Anchor anchor, List<HtmlTag> tags)
{
var index = tags.FindIndex(tag => tag.Name == "div" && tag.HasClass("unknownObject"));
if (index < 0 || tags.Any(tag => tag.Name == "div" && tag.HasClass("topLeft") &&
StyleValue(tag.Attribute("style"), "left") is not null))
{
return null;
}

var text = string.Join('\n', tags.Skip(index)
.Where(tag => tag.HasClass("unknownObjectText"))
.Select(tag => tag.Text.Trim())
.Where(line => line.Length > 0));
var width = PlaceholderSize.X;
var height = PlaceholderSize.Y;
return new MicrosoftWhiteboardShape(
anchor.ToCanvas(anchor.Centered ? default : new PointD(width / 2, height / 2)),
width * anchor.Scale,
height * anchor.Scale,
anchor.AngleDegrees,
ShapeKind.Rectangle,
CustomElementOutlineArgb,
0xFFFFFFFF,
anchor.Scale,
text,
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 16 * anchor.Scale, Black, false, false, false));
}

/// <summary>
/// A connector's SVG draws its route from the anchor, and its head as a small
/// path moved to one end. A route with corners is kept as a straight line.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public static void Run()
RunLive();
RunCards();
RunCustom();
RunPlaceholders();
}

/// <summary>
Expand Down Expand Up @@ -690,6 +691,48 @@ private static void RunCustom()
"A custom object without a size in pixels takes the smallest box, and a percentage is not a size.");
}

/// <summary>
/// Content the web client drew as a placeholder because a feature was off: a table,
/// a template, and a table inside a template whose other children were drawn.
/// </summary>
private const string PlaceholderPage = """
<html><body><div id="canvasContent" class="contentOrigin">
<div class="anchor align topLeft" data-whiteboard-type="Unknown" style="left: 100px; top: 100px;">
<div class="canvasChild"><div class="unknownObject interactive" tabindex="0" aria-label="Unknown object" role="figure"><div class="unknownObjectTextContainer">
<div id="unknownObjectHeaderText" class="unknownObjectText">This content can't be shown</div><div class="unknownObjectText">Update Microsoft Whiteboard to see it.</div></div></div></div>
</div>
<div class="anchor align center" data-whiteboard-type="LegacyTemplate" style="left: 1000px; top: 0px;">
<div class="canvasChild"><div class="unknownObject interactive" role="figure"><div class="unknownObjectTextContainer">
<div id="unknownObjectHeaderText" class="unknownObjectText">This content can't be shown</div><div class="unknownObjectText">Update Microsoft Whiteboard to see it.</div></div></div></div>
</div>
<div class="anchor align topLeft" data-whiteboard-type="LegacyTemplate" style="left: 0px; top: 1000px;">
<div class="canvasChild"><div class="collection" role="collection">
<div><div class="topLeft" style="left: 0px; top: 0px; width: 600px; height: 60px; position: absolute;">
<div class="textbox templateTitle"><div class="textBoxCore"><div data-block="true"><span data-text="true">Plan</span></div></div></div></div></div>
<div><div class="topLeft" style="left: 50px; top: 60px; position: absolute;">
<div class="unknownObject interactive" role="figure"><div class="unknownObjectTextContainer">
<div id="unknownObjectHeaderText" class="unknownObjectText">This content can't be shown</div><div class="unknownObjectText">Update Microsoft Whiteboard to see it.</div></div></div></div></div>
</div></div>
</div>
</div></body></html>
""";

private static void RunPlaceholders()
{
var board = MicrosoftWhiteboardExport.Parse(PlaceholderPage, "Placeholders");
Assert(board.Skipped.Count == 0, "A placeholder comes across as what the page shows, so nothing is left out.");

const string Text = "This content can't be shown\nUpdate Microsoft Whiteboard to see it.";
Assert(board.Items[0] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 448, Height: 170, FillArgb: 0xFFFFFFFF, Text: Text } table &&
table.Center == new PointD(324, 185),
"A table drawn as a placeholder is a white box of the placeholder's size with its two lines.");
Assert(board.Items[1] is MicrosoftWhiteboardShape { Text: Text } template && template.Center == new PointD(1000, 0),
"A template drawn as a placeholder is the same box, centered when its anchor is.");
Assert(board.Items.OfType<MicrosoftWhiteboardLabel>().Single() is { Text: "Plan" } &&
board.Items[^1] is MicrosoftWhiteboardShape { Text: Text } child && child.Center == new PointD(274, 1145),
"In a template whose children were drawn, a child drawn as a placeholder is the box, placed from its corner.");
}

private static void RunArchive()
{
var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));
Expand Down
Loading