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
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ Templates from boards made with the Windows 10 app of Microsoft Whiteboard now c
with their title and the notes, lists, text, pictures, shapes, and ink placed on them.
They used to be listed as left out.

### Loop components and apps from Microsoft Whiteboard
A Loop component now comes across as a link to it, and an app made by Copilot as an empty
panel of its size. They used to be listed as left out.
### Loop components, apps, work items, and frames from Microsoft Whiteboard
A Loop component now comes across as a link to it, an app made by Copilot as an empty
panel of its size, an Azure DevOps work item as a text container with its title, owner,
and state, and a frame Copilot drew as a panel with its title. They used to be listed as
left out.

## 1.7.3 - 30 September 2026

Expand Down
17 changes: 12 additions & 5 deletions docs/microsoft-whiteboard-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ no model of the board beyond what it draws.
| `Hyperlink` | A preview card with the page's picture, an `a` with the link and its title, and the description. |
| `LoopObject`, `HostedFluidObject`, `GroupFluidObject` | Live content that the export does not keep. A Loop component is a `div.loopParentDiv` with the component's address in `data-loop-url`; the export replaces its content with an `a` to that address around the picture `LoopExportThumbnail.svg`, which it does not embed. Not seen in a sample; read from the web client's `LoopComponentConnected` and export code. |
| `AppIframeHost` | An app that Copilot made, running in a sandboxed `iframe`. The export replaces the frame with a `#faf9f8` panel and keeps the `div.appFrameContainer` with its `width` and `height` and its header, whose `span.appFrameHeaderTitle` says *App* in the board's language. The app's name, code, and state are not in the export. Not seen in a sample; read from the web client's app frame component and export code. |
| `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. |
| `CommentThread` | A pin, described above. |

### Ink
Expand Down Expand Up @@ -135,6 +137,8 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C
| Legacy template | The title's bar as a filled rectangle and its text as a bold label, then each child read by the reader for what it holds: ink, a note, a note grid, a list, a text box, a picture, or a shape. A picture or a shape moves by half its size, because a child is placed from its corner and those are centered on their anchor at the top level. A child of any other kind is counted as a template item |
| Loop component | Markdown `TextBoardObject` titled *Loop*, linking to the component, as wide as it was |
| App made by Copilot | `ShapeBoardObject` rectangle of the frame's size in the panel's color, with the header's title as its text |
| 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 |
| 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 @@ -238,14 +242,17 @@ parses in about 160 ms.
- PowerPoint pages, which the Windows client cannot insert, videos, and Loop components
- SVG pictures, which the Windows client refuses to insert
- Groups, which the Windows client does not offer
- GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, and apps,
which are read from the web client's code, not from a sample
- GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, apps, work
items, and Copilot frames, which are read from the web client's code, not from a sample

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: `WorkItem`, `Frame`, and `CustomElement`,
an `Unknown` that holds no table, live content without a Loop element, and an app frame
without its box.
which the importer reports and leaves out: `CustomElement`, an `Unknown` that holds no
table, live content without a Loop element, an app frame without its box, and a work item
or a frame drawn as a placeholder.

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.

Each is reported by type after the import. Sticky notes cannot be
rotated in the Windows client, with the mouse, the pen, or touch.
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 @@ -225,7 +225,7 @@ <h2 id="what-comes-across">What comes across</h2>
<li>Sticky notes and note grids, as shapes in the same colors with their text.</li>
<li>Text boxes, lists, tables drawn with ink, connectors, and links.</li>
<li>Templates from the Windows 10 app, with their title and what is placed on them.</li>
<li>Loop components, as links to them.</li>
<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>
Expand Down
30 changes: 27 additions & 3 deletions src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ namespace SQLBI.Whiteboard.Core.Import;

/// <summary>
/// The start tags of an HTML page, in document order, with their attributes and the
/// text that follows each one up to the next tag. The Microsoft Whiteboard export is
/// generated markup, so quoting is regular, but it is HTML rather than XML: some
/// elements are never closed.
/// text that follows each one up to the next tag. Text that follows an end tag, such as
/// a title after a bold ID, comes as a tag named <c>#text</c>. The Microsoft Whiteboard
/// export is generated markup, so quoting is regular, but it is HTML rather than XML:
/// some elements are never closed.
/// </summary>
internal static class HtmlTags
{
Expand All @@ -16,6 +17,10 @@ internal static class HtmlTags
/// </summary>
private const int MaximumText = 65536;

public const string TextName = "#text";

private static readonly Dictionary<string, string> NoAttributes = new(StringComparer.OrdinalIgnoreCase);

public static IEnumerable<HtmlTag> StartTags(string html, int start)
{
var index = html.LastIndexOf('<', Math.Clamp(start, 0, Math.Max(0, html.Length - 1)));
Expand All @@ -28,6 +33,25 @@ public static IEnumerable<HtmlTag> StartTags(string html, int start)
yield break;
}

if (html[open + 1] == '/')
{
var close = html.IndexOf('>', open);
if (close < 0)
{
yield break;
}

var after = html.IndexOf('<', close);
var length = (after < 0 ? html.Length : after) - close - 1;
if (length is > 0 and < MaximumText && !html.AsSpan(close + 1, length).IsWhiteSpace())
{
yield return new HtmlTag(TextName, NoAttributes, WebUtility.HtmlDecode(html.Substring(close + 1, length)));
}

index = close + 1;
continue;
}

if (!char.IsAsciiLetter(html[open + 1]))
{
index = open + 1;
Expand Down
160 changes: 160 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ public sealed partial class MicrosoftWhiteboardExport
private const string CommentTitle = "Comment";
private const string ListTitle = "List";
private const string LoopTitle = "Loop";
private const string WorkItemTitle = "Work item";

/// <summary>
/// A work item card in the web client's stylesheet.
/// </summary>
private const double WorkItemWidth = 220;
private const double WorkItemHeight = 145;

/// <summary>
/// A Copilot frame in the web client's stylesheet: a 3-pixel border, and a title
/// bar with bold 15-pixel text padded 14 pixels in, centered in its 44 pixels.
/// </summary>
private const double FrameBorder = 3;
private const double FrameTitleFontSize = 15;
private static readonly PointD FrameTitleInset = new(17, 14);

/// <summary>
/// A Copilot frame's border, translucent fill, and title color for each theme.
/// </summary>
private static readonly Dictionary<string, (uint Border, uint Fill, uint Text)> FrameThemes = new(StringComparer.Ordinal)
{
["neutral"] = (0xFF6B6967, 0xC2FAF9F8, 0xFF323130),
["blue"] = (0xFF314AB2, 0xD1EFF3FF, 0xFF314AB2),
["green"] = (0xFF498205, 0xD1F1FAE8, 0xFF3B6A04),
["purple"] = (0xFF8764B8, 0xD1F9F5FF, 0xFF5C2E91),
["orange"] = (0xFFCA5010, 0xD1FFF4EC, 0xFFA4262C),
};

/// <summary>
/// How wide a Loop component's container is when the page gives no size.
Expand Down Expand Up @@ -261,6 +288,29 @@ void Skip(string what, int count = 1)
items.Add(link);
}

break;
case "WorkItem":
if (ReadWorkItem(anchor, tags) is { } workItem)
{
items.Add(workItem);
}
else
{
Skip(anchor.Type);
}

break;
case "Frame":
var frame = ReadFrame(anchor, tags).ToArray();
if (frame.Length > 0)
{
items.AddRange(frame);
}
else
{
Skip(anchor.Type);
}

break;
case "LoopObject":
case "HostedFluidObject":
Expand Down Expand Up @@ -1209,6 +1259,116 @@ private static IEnumerable<MicrosoftWhiteboardItem> ReadTemplateChild(Anchor pla
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20 * anchor.Scale, Black, false, false, false));
}

/// <summary>
/// An Azure DevOps work item, drawn as a card when the web client's
/// <c>EnableWorkItems</c> flag is on: a bold line with the type's icon and the ID,
/// then the title, who it is assigned to, and its state. It becomes Markdown titled
/// <c>Work item</c> with those lines, as wide as the card.
/// </summary>
private static MicrosoftWhiteboardTextContainer? ReadWorkItem(Anchor anchor, List<HtmlTag> tags)
{
var cardIndex = tags.FindIndex(tag => tag.Name == "div" && tag.HasClass("WorkItem"));
if (cardIndex < 0)
{
return null;
}

var fieldIndex = tags.FindIndex(cardIndex, tag => tag.HasClass("WorkItemTextField"));
var texts = new List<(int Index, string Text)>();
for (var index = cardIndex; index < tags.Count; index++)
{
if (!string.IsNullOrWhiteSpace(tags[index].Text))
{
texts.Add((index, tags[index].Text.Trim()));
}
}

// The field holds the ID after the icon and the title after the bold part. What
// follows it is who the item is assigned to, then its state.
var type = tags.Skip(cardIndex).FirstOrDefault(tag => tag.HasClass("WorkItemIcon"))?.Attribute("alt");
var field = fieldIndex < 0 ? [] : texts.Where(text => text.Index >= fieldIndex).Take(2).ToArray();
var id = field.Length > 1 && field[0].Text.All(char.IsAsciiDigit) ? field[0].Text : null;
var title = id is null ? field.FirstOrDefault().Text : field[1].Text;
var rest = texts.Where(text => field.All(item => item.Index != text.Index)).Select(text => text.Text).ToArray();
if (string.IsNullOrEmpty(title) && rest.Length == 0)
{
return null;
}

var heading = string.Join(' ', new[] { Capitalized(type), id }.Where(part => !string.IsNullOrEmpty(part)));
var lines = new List<string>
{
(heading.Length > 0 ? $"**{EscapeMarkdown(heading)}** " : string.Empty) + EscapeMarkdown(title ?? string.Empty),
};
if (rest.Length > 0)
{
lines.Add(string.Join(" \n", new[] { rest[0], string.Join(' ', rest.Skip(1)) }
.Where(line => line.Length > 0)
.Select(EscapeMarkdown)));
}

return new MicrosoftWhiteboardTextContainer(
anchor.ToCanvas(anchor.Centered ? new PointD(-WorkItemWidth / 2, -WorkItemHeight / 2) : default),
WorkItemWidth * anchor.Scale,
WorkItemTitle,
string.Join("\n\n", lines.Where(line => line.Trim().Length > 0)));
}

private static string? Capitalized(string? text) =>
string.IsNullOrEmpty(text) ? text : char.ToUpperInvariant(text[0]) + text[1..];

/// <summary>
/// A frame that Copilot drew around objects it grouped, when the web client's
/// <c>EnableFrames</c> flag is on: a rounded, translucent panel with a border and a
/// title bar in one of five themes. It becomes a rectangle in the theme's colors
/// with its title as a bold label in the bar: its corners are rounded by 14 pixels,
/// which on a frame is closer to square than to a rounded rectangle here. Its sources are left out,
/// because the export keeps only the first one's name.
/// </summary>
private static IEnumerable<MicrosoftWhiteboardItem> ReadFrame(Anchor anchor, List<HtmlTag> tags)
{
var frame = tags.FirstOrDefault(tag => tag.HasClass("whiteboardFrame"));
var width = StyleLength(frame?.Attribute("style"), "width") ?? 0;
var height = StyleLength(frame?.Attribute("style"), "height") ?? 0;
if (frame is null || width <= 0 || height <= 0)
{
yield break;
}

var theme = FrameThemes.Keys.FirstOrDefault(key => frame.HasClass($"whiteboardFrame--{key}")) ?? "neutral";
var (border, fill, text) = FrameThemes[theme];
yield return new MicrosoftWhiteboardShape(
anchor.ToCanvas(anchor.Centered ? default : new PointD(width / 2, height / 2)),
width * anchor.Scale,
height * anchor.Scale,
anchor.AngleDegrees,
ShapeKind.Rectangle,
border,
fill,
FrameBorder * anchor.Scale,
string.Empty,
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20, Black, false, false, false));

var title = tags.FirstOrDefault(tag => tag.HasClass("whiteboardFrame__titleText"))?.Text.Trim();
if (string.IsNullOrEmpty(title))
{
title = frame.Attribute("aria-label")?.Trim();
}

if (!string.IsNullOrEmpty(title))
{
yield return new MicrosoftWhiteboardLabel(
anchor.ToCanvas(anchor.Centered ? new PointD(-width / 2, -height / 2) : default),
anchor.Scale,
anchor.AngleDegrees,
FrameTitleInset,
title,
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, FrameTitleFontSize, text, true, false, false),
Math.Max(1, width - (2 * FrameTitleInset.X)),
null);
}
}

/// <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
Loading
Loading