diff --git a/CHANGELOG.md b/CHANGELOG.md index 957db9f..89c0203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ 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. + ## 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 5a72d56..abe0d12 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -1,14 +1,14 @@ # Microsoft Whiteboard import -**Implemented in 1.7.0**, with the objects of older versions of the app in 1.7.1, -1.7.2, 1.7.3, and 1.7.4. This document records what a Microsoft Whiteboard export contains, how the -importer maps it onto a board, and what it cannot carry over. Microsoft retires its +**Implemented in 1.7.0**, with the objects of older versions of the app in 1.7.1, 1.7.2, +1.7.3, and 1.7.4. This document records what a Microsoft Whiteboard export contains, how +the importer maps it onto a board, and what it cannot carry over. Microsoft retires its standalone Whiteboard apps on 16 October 2026 (see [retirement](retirement/README.md)), and the export is the only way to take a board out of it. The format is undocumented. Everything below was established from eight exports of real teaching boards and one test board holding every kind of object the Windows client offers, made between 20 and 27 -September 2026. Six more teaching boards, exported on 29 September, added the objects -that older versions of the app wrote. +September 2026. Six more teaching boards, exported on 29 September, added the objects that +older versions of the app wrote. ## Where it starts @@ -69,6 +69,8 @@ no model of the board beyond what it draws. | `Connector` | An SVG with the route as a path from the anchor, and the head as a small path moved to one end with `translate`. | | `Unknown` holding `div.inkTableContainer` | A table drawn with ink in the Windows 10 app, which the web client registers under no type of its own. The container is a CSS grid whose `grid-template-columns` and `grid-template-rows` give each column and row as `minmax(px, auto)`. One `div.inkCellContainer` per cell, row by row, has a `4px solid` border in the table's color and a `-2px` margin, so the border sits over the grid line. Inside it, a `div` shifts the cell's ink by `left` and `top`, and each ink group sits in a `div` with its own `left`, `top`, and `transform`, holding the same SVG an `InkGroup` has. The web client draws a placeholder instead when its `EnableTables` flag is off. Not seen in a sample; read from the web client's `TableComponent`. | | `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. | | `CommentThread` | A pin, described above. | ### Ink @@ -131,6 +133,8 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | Ink table | One `ShapeBoardObject` rectangle per cell with the border's color and 4-unit outline, then the cells' ink as strokes | | Legacy sticker | The picture, and its caption as a `FreeTextBoardObject` centered across the caption's box | | 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 | | 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 | @@ -234,15 +238,14 @@ 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, and legacy templates, which are read from the - web client's code, not from a sample +- 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 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: `LoopObject`, -`HostedFluidObject`, `GroupFluidObject`, `AppIframeHost`, `WorkItem`, `Frame`, and -`CustomElement`, and an `Unknown` that holds no table. The export replaces a Loop component and an app frame with a -placeholder picture. +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. Each is reported by type after the import. Sticky notes cannot be rotated in the Windows client, with the mouse, the pen, or touch. diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index c78ec34..490db7e 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -225,6 +225,7 @@

What comes across

  • Sticky notes and note grids, as shapes in the same colors with their text.
  • Text boxes, lists, tables drawn with ink, connectors, and links.
  • Templates from the Windows 10 app, with their title and what is placed on them.
  • +
  • Loop components, as links to them.
  • 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.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index c65efd8..89b1e6d 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -28,6 +28,18 @@ public sealed partial class MicrosoftWhiteboardExport private const string CommentsSuffix = "-comments.json"; private const string CommentTitle = "Comment"; private const string ListTitle = "List"; + private const string LoopTitle = "Loop"; + + /// + /// How wide a Loop component's container is when the page gives no size. + /// + private const double LoopWidth = 400; + + /// + /// The panel the export leaves where an app ran, and a neutral outline around it. + /// + private const uint AppFramePanelArgb = 0xFFFAF9F8; + private const uint AppFrameOutlineArgb = 0xFFE1DFDD; /// /// A table cell's border, which the web client draws over the grid line. @@ -249,6 +261,30 @@ void Skip(string what, int count = 1) items.Add(link); } + break; + case "LoopObject": + case "HostedFluidObject": + case "GroupFluidObject": + if (ReadLoop(anchor, tags) is { } loop) + { + items.Add(loop); + } + else + { + Skip(anchor.Type); + } + + break; + case "AppIframeHost": + if (ReadAppFrame(anchor, tags) is { } app) + { + items.Add(app); + } + else + { + Skip(anchor.Type); + } + break; case "LegacyTemplate": items.AddRange(ReadTemplate(anchor, tags, what => Skip(what))); @@ -1116,6 +1152,63 @@ private static IEnumerable ReadTemplateChild(Anchor pla } } + /// + /// A Loop component is live content that the export does not keep. The export puts + /// a picture in its place, inside a link to the component when it has one, and the + /// component's element keeps the address in data-loop-url. It becomes + /// Markdown titled Loop with that link, as wide as the component. + /// + private static MicrosoftWhiteboardTextContainer? ReadLoop(Anchor anchor, List tags) + { + var loop = tags.FirstOrDefault(tag => tag.Name == "div" && tag.HasClass("loopParentDiv")); + if (loop is null) + { + return null; + } + + var url = loop.Attribute("data-loop-url") ?? + tags.FirstOrDefault(tag => tag.Name == "a" && tag.Attribute("href") is not null)?.Attribute("href"); + var sized = tags.FirstOrDefault(tag => + StyleLength(tag.Attribute("style"), "width") > 0 && StyleLength(tag.Attribute("style"), "height") > 0); + var width = StyleLength(sized?.Attribute("style"), "width") ?? LoopWidth; + var height = StyleLength(sized?.Attribute("style"), "height") ?? 0; + return new MicrosoftWhiteboardTextContainer( + anchor.ToCanvas(anchor.Centered ? new PointD(-width / 2, -height / 2) : default), + width * anchor.Scale, + LoopTitle, + string.IsNullOrWhiteSpace(url) ? "Loop component" : $"[Loop component]({url})"); + } + + /// + /// An app that Copilot made runs in a frame the export does not keep. The export + /// leaves the frame's box, its header with its title, and a plain panel where the + /// app ran. It becomes a rectangle of that size in the panel's color, with the + /// header's title as its text. + /// + private static MicrosoftWhiteboardShape? ReadAppFrame(Anchor anchor, List tags) + { + var box = tags.FirstOrDefault(tag => tag.Name == "div" && tag.HasClass("appFrameContainer")); + var width = StyleLength(box?.Attribute("style"), "width") ?? 0; + var height = StyleLength(box?.Attribute("style"), "height") ?? 0; + if (width <= 0 || height <= 0) + { + return null; + } + + var title = tags.FirstOrDefault(tag => tag.HasClass("appFrameHeaderTitle"))?.Text.Trim(); + return new MicrosoftWhiteboardShape( + anchor.ToCanvas(anchor.Centered ? default : new PointD(width / 2, height / 2)), + width * anchor.Scale, + height * anchor.Scale, + anchor.AngleDegrees, + ShapeKind.Rectangle, + AppFrameOutlineArgb, + AppFramePanelArgb, + anchor.Scale, + string.IsNullOrEmpty(title) ? "App" : title, + new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20 * anchor.Scale, Black, false, false, false)); + } + /// /// 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. @@ -1572,7 +1665,11 @@ private static double Distance(PointD a, PointD b) => /// size at left and top, and its transform scales, turns, and moves it around that /// point. Most objects start at the point, and pictures and shapes are centered on it. /// - private sealed record Anchor(string Type, PointD Origin, double A, double B, double C, double D, double E, double F) + /// + /// Centered is the center class, which puts a picture, a shape, and other + /// boxed content in the middle of the point rather than below and right of it. + /// + private sealed record Anchor(string Type, PointD Origin, double A, double B, double C, double D, double E, double F, bool Centered = false) { public double Scale => Math.Sqrt(Math.Abs((A * D) - (B * C))); @@ -1586,7 +1683,7 @@ public static Anchor From(HtmlTag tag, string type) var style = tag.Attribute("style"); var origin = new PointD(StyleLength(style, "left") ?? 0, StyleLength(style, "top") ?? 0); var matrix = StyleMatrix(style); - return new Anchor(type, origin, matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]); + return new Anchor(type, origin, matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5], tag.HasClass("center")); } public PointD ToCanvas(PointD local) => new( diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index 65a2407..dbeeefa 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -141,6 +141,7 @@ public static void Run() RunLists(); RunTables(); RunTemplate(); + RunLive(); } /// @@ -570,6 +571,46 @@ private static void RunTemplate() Assert(board.Items.Count == 6, "Nothing else is read from the template."); } + /// + /// Loop components and an app as the export leaves them: a Loop element holding a + /// link around a placeholder picture, and an app frame whose frame became a panel. + /// + private const string LivePage = """ +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + """; + + private static void RunLive() + { + var board = MicrosoftWhiteboardExport.Parse(LivePage, "Live"); + Assert(board.Skipped.Count == 1 && board.Skipped["HostedFluidObject"] == 1, + "Live content without a Loop element in it is still reported."); + + Assert(board.Items[0] is MicrosoftWhiteboardTextContainer { Title: "Loop", Width: 600 } linked && + linked.TopLeft == new PointD(100, 100) && + linked.Markdown == "[Loop component](https://loop.cloud.microsoft/p/abc)", + "A Loop component is Markdown linking to it, as wide as the component."); + Assert(board.Items[1] is MicrosoftWhiteboardTextContainer { Width: 400, Markdown: "Loop component" } unlinked && + unlinked.TopLeft == new PointD(800, 1000), + "A Loop component without an address says what it was, and a centered one is centered on its anchor."); + Assert(board.Items[2] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 480, Height: 320, FillArgb: 0xFFFAF9F8, Text: "App" } app && + app.Center == new PointD(240, 660), + "An app is a panel of its size with its header's title."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));