Add writing workspace launcher
This commit is contained in:
parent
451e384966
commit
43a57df070
@ -81,6 +81,11 @@ var tests = new (string Name, Action Test)[]
|
||||
("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
|
||||
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
|
||||
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure),
|
||||
("Writing workspace presence scopes browser windows", WritingWorkspacePresenceScopesBrowserWindows),
|
||||
("Writing workspace client uses session window identity and stable names", WritingWorkspaceClientUsesSessionIdentityAndStableNames),
|
||||
("Writing workspace launcher uses progressive screen placement", WritingWorkspaceLauncherUsesProgressiveScreenPlacement),
|
||||
("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior),
|
||||
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
|
||||
("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
|
||||
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
|
||||
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
|
||||
@ -269,6 +274,134 @@ static void WritingPairSharesCanonicalFollowInfrastructure()
|
||||
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
|
||||
}
|
||||
|
||||
static void WritingWorkspacePresenceScopesBrowserWindows()
|
||||
{
|
||||
var service = new WritingWorkspacePresenceService();
|
||||
var word = new WordCompanionPresenceStatus
|
||||
{
|
||||
UserID = 9,
|
||||
IsConnected = true,
|
||||
DocumentOpen = true,
|
||||
LinkedProjectID = 10,
|
||||
LinkedBookID = 20
|
||||
};
|
||||
|
||||
service.RegisterAsync(9, "launcher-1", new WritingWindowRegistration
|
||||
{
|
||||
BrowserWindowID = "launcher",
|
||||
ProjectID = 10,
|
||||
BookID = 20,
|
||||
WritingRole = WritingWorkspaceRoles.Launcher,
|
||||
CurrentRoute = "/Writer"
|
||||
}, word).GetAwaiter().GetResult();
|
||||
service.RegisterAsync(9, "context-1", new WritingWindowRegistration
|
||||
{
|
||||
BrowserWindowID = "context",
|
||||
ProjectID = 10,
|
||||
BookID = 20,
|
||||
WritingRole = WritingWorkspaceRoles.Context,
|
||||
CurrentRoute = "/Writing/Context"
|
||||
}, word).GetAwaiter().GetResult();
|
||||
service.RegisterAsync(9, "intent-1", new WritingWindowRegistration
|
||||
{
|
||||
BrowserWindowID = "intent",
|
||||
ProjectID = 10,
|
||||
BookID = 20,
|
||||
WritingRole = WritingWorkspaceRoles.Intent,
|
||||
CurrentRoute = "/Writing/Intent"
|
||||
}, word).GetAwaiter().GetResult();
|
||||
service.RegisterAsync(9, "other-context", new WritingWindowRegistration
|
||||
{
|
||||
BrowserWindowID = "other-context",
|
||||
ProjectID = 10,
|
||||
BookID = 21,
|
||||
WritingRole = WritingWorkspaceRoles.Context,
|
||||
CurrentRoute = "/Writing/Context"
|
||||
}, word).GetAwaiter().GetResult();
|
||||
|
||||
var status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
|
||||
Assert(status.WordCompanionConnected && status.WordDocumentOpen, "Writing workspace should include Word Companion presence.");
|
||||
Assert(status.ContextOpen && status.IntentOpen, "Matching Context and Intent windows should be open.");
|
||||
Assert(status.Context?.BrowserWindowID == "context", "Workspace status should choose the matching book's Context window.");
|
||||
Assert(status.Windows.All(window => window.BookID == 20), "Workspace status should not include another book's windows.");
|
||||
|
||||
service.DisconnectAsync("context-1", word).GetAwaiter().GetResult();
|
||||
status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
|
||||
Assert(!status.ContextOpen && status.IntentOpen, "Disconnected Context should be removed without affecting Intent.");
|
||||
}
|
||||
|
||||
static void WritingWorkspaceClientUsesSessionIdentityAndStableNames()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs"));
|
||||
|
||||
Assert(script.Contains("sessionStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must come from sessionStorage.");
|
||||
Assert(script.Contains("sessionStorage.setItem(storageKeys.windowId", StringComparison.Ordinal), "Browser window IDs must be stored per tab/window.");
|
||||
Assert(!script.Contains("localStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must not be shared through localStorage.");
|
||||
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Context and Intent windows should use stable named targets.");
|
||||
Assert(script.Contains("Book_${bookId}", StringComparison.Ordinal), "Stable writing window names should be book scoped.");
|
||||
Assert(script.Contains("windowRefs = new Map()", StringComparison.Ordinal), "Launcher-created window references should be retained for focus.");
|
||||
Assert(script.Contains("RequestWritingWindowFocus", StringComparison.Ordinal), "Launcher should fall back to SignalR focus requests.");
|
||||
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal), "Writing workspace windows should re-register after SignalR reconnect.");
|
||||
Assert(service.Contains("ActiveTimeout = TimeSpan.FromSeconds(45)", StringComparison.Ordinal) && service.Contains("MarkStaleOfflineAsync", StringComparison.Ordinal), "Writing workspace presence should expire stale windows.");
|
||||
}
|
||||
|
||||
static void WritingWorkspaceLauncherUsesProgressiveScreenPlacement()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
|
||||
var launcher = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace-launcher.js"));
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Writer/Index.cshtml"));
|
||||
|
||||
Assert(script.Contains("getScreenDetails", StringComparison.Ordinal), "Workspace launcher should use the Window Management API when available.");
|
||||
Assert(script.Contains("window-management", StringComparison.Ordinal), "Workspace launcher should check/request window-management permission.");
|
||||
Assert(script.Contains("sort((a, b) => a.left - b.left", StringComparison.Ordinal), "Screen topology should be derived from physical coordinates.");
|
||||
Assert(script.Contains("mode: \"three-screen\"", StringComparison.Ordinal), "Three-screen layout decision is missing.");
|
||||
Assert(script.Contains("contextDisplay: preferences.contextDisplay === \"right\" ? ordered[ordered.length - 1] : ordered[0]", StringComparison.Ordinal), "Context should default to the left display.");
|
||||
Assert(script.Contains("intentDisplay: preferences.intentDisplay === \"left\" ? ordered[0] : ordered[ordered.length - 1]", StringComparison.Ordinal), "Intent should default to the right display.");
|
||||
Assert(script.Contains("mode: \"two-screen\"", StringComparison.Ordinal), "Two-screen fallback is missing.");
|
||||
Assert(script.Contains("mode: \"single-screen\"", StringComparison.Ordinal), "One-screen fallback is missing.");
|
||||
Assert(script.Contains("localStorage.setItem(storageKeys.preferences", StringComparison.Ordinal), "Remembered layout preferences are missing.");
|
||||
Assert(script.Contains("window.open(url, name, popupFeatures(display))", StringComparison.Ordinal), "Writing windows should be opened from explicit launcher clicks with placement features.");
|
||||
Assert(launcher.Contains("requestScreens: true", StringComparison.Ordinal), "Screen permission should be requested from the Start action.");
|
||||
Assert(view.Contains("data-start-writing-workspace", StringComparison.Ordinal), "Writer dashboard launcher start button is missing.");
|
||||
Assert(view.Contains("data-focus-writing-context", StringComparison.Ordinal), "Focus Context action is missing.");
|
||||
Assert(view.Contains("data-focus-writing-intent", StringComparison.Ordinal), "Focus Intent action is missing.");
|
||||
}
|
||||
|
||||
static void WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
|
||||
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
|
||||
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
|
||||
Assert(contextView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.context", StringComparison.Ordinal), "Writing Context should register its workspace role.");
|
||||
Assert(intentView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.intent", StringComparison.Ordinal), "Writing Intent should register its workspace role.");
|
||||
Assert(contextView.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should remain a writing-mode page.");
|
||||
Assert(intentView.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should remain a writing-mode page.");
|
||||
Assert(follow.Contains("registerWorkspaceRole", StringComparison.Ordinal), "Writing follow should refresh presence scope after Word updates.");
|
||||
Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Existing canonical Word follow should remain.");
|
||||
Assert(!follow.Contains("window.location.assign", StringComparison.Ordinal) && !follow.Contains("window.location.href", StringComparison.Ordinal), "Writing pages must still update in place without navigation.");
|
||||
}
|
||||
|
||||
static void WordCompanionExposesWritingWorkspaceLaunchActions()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||
|
||||
Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose Open Workspace.");
|
||||
Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose Open Context.");
|
||||
Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose Open Intent.");
|
||||
Assert(script.Contains("writingWindowName", StringComparison.Ordinal), "Word Companion should use stable writing window names.");
|
||||
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Word Companion writing window names should be scoped.");
|
||||
Assert(script.Contains("openWritingWorkspaceButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Workspace button should be wired.");
|
||||
Assert(script.Contains("openWritingContextButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Context button should be wired.");
|
||||
Assert(script.Contains("openWritingIntentButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Intent button should be wired.");
|
||||
}
|
||||
|
||||
static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
|
||||
@ -10,6 +10,7 @@ namespace PlotLine.Hubs;
|
||||
[Authorize]
|
||||
public sealed class WordCompanionFollowHub(
|
||||
IWordCompanionPresenceService presence,
|
||||
IWritingWorkspacePresenceService writingPresence,
|
||||
IOnboardingRepository onboarding,
|
||||
IManuscriptScanPreviewStore scanStore,
|
||||
IOnboardingService onboardingService) : Hub
|
||||
@ -41,6 +42,7 @@ public sealed class WordCompanionFollowHub(
|
||||
|
||||
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
|
||||
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
|
||||
return status;
|
||||
}
|
||||
|
||||
@ -49,9 +51,51 @@ public sealed class WordCompanionFollowHub(
|
||||
var userId = RequireUserId();
|
||||
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
|
||||
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<WritingWorkspaceStatus> WatchWritingWorkspace(WritingWorkspaceScope scope)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
||||
return await WritingWorkspaceStatusAsync(userId, scope);
|
||||
}
|
||||
|
||||
public async Task<WritingWorkspaceStatus> RegisterWritingWindow(WritingWindowRegistration registration)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
||||
var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(userId));
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<WritingWorkspaceStatus> WritingWindowHeartbeat(WritingWindowHeartbeat heartbeat)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
var status = await writingPresence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat, await presence.GetStatusAsync(userId));
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<WritingWorkspaceStatus> RequestWritingWindowFocus(WritingWorkspaceScope scope, string role)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
var connectionId = await writingPresence.GetConnectionIdForRoleAsync(userId, scope, role);
|
||||
if (!string.IsNullOrWhiteSpace(connectionId))
|
||||
{
|
||||
await Clients.Client(connectionId).SendAsync("WritingWorkspaceFocusRequested", new
|
||||
{
|
||||
WritingRole = WritingWorkspaceRoles.Normalise(role),
|
||||
scope.ProjectID,
|
||||
scope.BookID
|
||||
});
|
||||
}
|
||||
|
||||
return await WritingWorkspaceStatusAsync(userId, scope);
|
||||
}
|
||||
|
||||
public async Task<ManuscriptScanState> StartOnboardingManuscriptScan()
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
@ -163,10 +207,13 @@ public sealed class WordCompanionFollowHub(
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
int? disconnectedUserId = null;
|
||||
var status = await presence.DisconnectAsync(Context.ConnectionId);
|
||||
if (status is not null)
|
||||
{
|
||||
disconnectedUserId = status.UserID;
|
||||
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
|
||||
await BroadcastWritingWorkspaceStatusAsync(status.UserID, status.LinkedProjectID, status.LinkedBookID);
|
||||
var failedScans = await scanStore.FailRunningForUserAsync(
|
||||
status.UserID,
|
||||
"The Word Companion disconnected before the scan finished. Reopen Word and try again.");
|
||||
@ -176,9 +223,30 @@ public sealed class WordCompanionFollowHub(
|
||||
}
|
||||
}
|
||||
|
||||
var writingStatus = await writingPresence.DisconnectAsync(
|
||||
Context.ConnectionId,
|
||||
disconnectedUserId.HasValue ? await presence.GetStatusAsync(disconnectedUserId.Value) : null);
|
||||
if (writingStatus is not null)
|
||||
{
|
||||
await Clients.Group(PresenceGroup(writingStatus.UserID)).SendAsync("WritingWorkspacePresenceChanged", writingStatus);
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private async Task<WritingWorkspaceStatus> WritingWorkspaceStatusAsync(int userId, WritingWorkspaceScope scope)
|
||||
=> await writingPresence.GetStatusAsync(userId, scope, await presence.GetStatusAsync(userId));
|
||||
|
||||
private async Task BroadcastWritingWorkspaceStatusAsync(int userId, int? projectId, int? bookId)
|
||||
{
|
||||
var workspaceStatus = await WritingWorkspaceStatusAsync(userId, new WritingWorkspaceScope
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId
|
||||
});
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", workspaceStatus);
|
||||
}
|
||||
|
||||
private int RequireUserId()
|
||||
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion.");
|
||||
|
||||
|
||||
76
PlotLine/Models/WritingWorkspacePresenceModels.cs
Normal file
76
PlotLine/Models/WritingWorkspacePresenceModels.cs
Normal file
@ -0,0 +1,76 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public static class WritingWorkspaceRoles
|
||||
{
|
||||
public const string None = "None";
|
||||
public const string Launcher = "Launcher";
|
||||
public const string Context = "Context";
|
||||
public const string Intent = "Intent";
|
||||
|
||||
public static string Normalise(string? role)
|
||||
{
|
||||
var value = (role ?? string.Empty).Trim();
|
||||
return value.Equals(Launcher, StringComparison.OrdinalIgnoreCase) ? Launcher
|
||||
: value.Equals(Context, StringComparison.OrdinalIgnoreCase) ? Context
|
||||
: value.Equals(Intent, StringComparison.OrdinalIgnoreCase) ? Intent
|
||||
: None;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WritingWorkspaceScope
|
||||
{
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WritingWindowRegistration
|
||||
{
|
||||
public string? BrowserWindowID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string? CurrentRoute { get; init; }
|
||||
public string? WritingRole { get; init; }
|
||||
public string? DisplayLabel { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WritingWindowHeartbeat
|
||||
{
|
||||
public string? BrowserWindowID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string? CurrentRoute { get; init; }
|
||||
public string? WritingRole { get; init; }
|
||||
public string? DisplayLabel { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WritingWindowPresence
|
||||
{
|
||||
public string BrowserWindowID { get; init; } = string.Empty;
|
||||
public int UserID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string CurrentRoute { get; init; } = string.Empty;
|
||||
public string WritingRole { get; init; } = WritingWorkspaceRoles.None;
|
||||
public string? DisplayLabel { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
public DateTime ConnectedUtc { get; init; }
|
||||
public DateTime LastSeenUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WritingWorkspaceStatus
|
||||
{
|
||||
public int UserID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public bool WordCompanionConnected { get; init; }
|
||||
public bool WordDocumentOpen { get; init; }
|
||||
public int? WordProjectID { get; init; }
|
||||
public int? WordBookID { get; init; }
|
||||
public StoryPosition? CurrentStoryPosition { get; init; }
|
||||
public IReadOnlyList<WritingWindowPresence> Windows { get; init; } = [];
|
||||
public WritingWindowPresence? Launcher { get; init; }
|
||||
public WritingWindowPresence? Context { get; init; }
|
||||
public WritingWindowPresence? Intent { get; init; }
|
||||
public bool ContextOpen => Context?.IsActive == true;
|
||||
public bool IntentOpen => Intent?.IsActive == true;
|
||||
}
|
||||
@ -195,6 +195,7 @@ public class Program
|
||||
builder.Services.AddScoped<IImportService, ImportService>();
|
||||
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
|
||||
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
|
||||
builder.Services.AddSingleton<IWritingWorkspacePresenceService, WritingWorkspacePresenceService>();
|
||||
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
|
||||
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
|
||||
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PlotLine.Hubs;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed class WordCompanionPresenceMonitor(
|
||||
IWordCompanionPresenceService presence,
|
||||
IWritingWorkspacePresenceService writingPresence,
|
||||
IManuscriptScanPreviewStore scanStore,
|
||||
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
|
||||
{
|
||||
@ -21,6 +23,14 @@ public sealed class WordCompanionPresenceMonitor(
|
||||
await hub.Clients
|
||||
.Group(PresenceGroup(status.UserID))
|
||||
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken);
|
||||
var workspaceStatus = await writingPresence.GetStatusAsync(status.UserID, new WritingWorkspaceScope
|
||||
{
|
||||
ProjectID = status.LinkedProjectID,
|
||||
BookID = status.LinkedBookID
|
||||
}, status);
|
||||
await hub.Clients
|
||||
.Group(PresenceGroup(status.UserID))
|
||||
.SendAsync("WritingWorkspacePresenceChanged", workspaceStatus, stoppingToken);
|
||||
var failedScans = await scanStore.FailRunningForUserAsync(
|
||||
status.UserID,
|
||||
"The Word Companion disconnected before the scan finished. Reopen Word and try again.");
|
||||
@ -29,10 +39,18 @@ public sealed class WordCompanionPresenceMonitor(
|
||||
await hub.Clients
|
||||
.Group(PresenceGroup(status.UserID))
|
||||
.SendAsync("OnboardingScanFailed", failedScan, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var staleWorkspaceStatuses = await writingPresence.MarkStaleOfflineAsync(presence.GetStatusAsync);
|
||||
foreach (var workspaceStatus in staleWorkspaceStatuses)
|
||||
{
|
||||
await hub.Clients
|
||||
.Group(PresenceGroup(workspaceStatus.UserID))
|
||||
.SendAsync("WritingWorkspacePresenceChanged", workspaceStatus, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
|
||||
217
PlotLine/Services/WritingWorkspacePresenceService.cs
Normal file
217
PlotLine/Services/WritingWorkspacePresenceService.cs
Normal file
@ -0,0 +1,217 @@
|
||||
using System.Collections.Concurrent;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IWritingWorkspacePresenceService
|
||||
{
|
||||
Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus);
|
||||
Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus);
|
||||
Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus);
|
||||
Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus);
|
||||
Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser);
|
||||
Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role);
|
||||
}
|
||||
|
||||
public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceService
|
||||
{
|
||||
private static readonly TimeSpan ActiveTimeout = TimeSpan.FromSeconds(45);
|
||||
private readonly ConcurrentDictionary<string, WindowRecord> windowsById = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, string> windowIdsByConnection = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var windowId = Clean(registration.BrowserWindowID);
|
||||
if (windowId is null)
|
||||
{
|
||||
windowId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
var record = windowsById.AddOrUpdate(
|
||||
WindowKey(userId, windowId),
|
||||
_ => new WindowRecord
|
||||
{
|
||||
BrowserWindowID = windowId,
|
||||
UserID = userId,
|
||||
ConnectedUtc = now
|
||||
},
|
||||
(_, existing) => existing);
|
||||
|
||||
Apply(record, connectionId, registration.ProjectID, registration.BookID, registration.CurrentRoute, registration.WritingRole, registration.DisplayLabel, now);
|
||||
windowIdsByConnection[connectionId] = WindowKey(userId, windowId);
|
||||
return Task.FromResult(BuildStatus(userId, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus));
|
||||
}
|
||||
|
||||
public Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus)
|
||||
{
|
||||
var registration = new WritingWindowRegistration
|
||||
{
|
||||
BrowserWindowID = heartbeat.BrowserWindowID,
|
||||
ProjectID = heartbeat.ProjectID,
|
||||
BookID = heartbeat.BookID,
|
||||
CurrentRoute = heartbeat.CurrentRoute,
|
||||
WritingRole = heartbeat.WritingRole,
|
||||
DisplayLabel = heartbeat.DisplayLabel
|
||||
};
|
||||
return RegisterAsync(userId, connectionId, registration, wordStatus);
|
||||
}
|
||||
|
||||
public Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus)
|
||||
{
|
||||
if (!windowIdsByConnection.TryRemove(connectionId, out var key)
|
||||
|| !windowsById.TryGetValue(key, out var record)
|
||||
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<WritingWorkspaceStatus?>(null);
|
||||
}
|
||||
|
||||
record.IsActive = false;
|
||||
record.ConnectionID = string.Empty;
|
||||
var status = BuildStatus(record.UserID, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus ?? new WordCompanionPresenceStatus { UserID = record.UserID });
|
||||
return Task.FromResult<WritingWorkspaceStatus?>(status);
|
||||
}
|
||||
|
||||
public Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
|
||||
=> Task.FromResult(BuildStatus(userId, scope, wordStatus));
|
||||
|
||||
public async Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var changedScopes = new Dictionary<string, (int UserID, int? ProjectID, int? BookID)>(StringComparer.Ordinal);
|
||||
foreach (var record in windowsById.Values)
|
||||
{
|
||||
if (!record.IsActive || now - record.LastSeenUtc <= ActiveTimeout)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
record.IsActive = false;
|
||||
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
|
||||
{
|
||||
windowIdsByConnection.TryRemove(record.ConnectionID, out _);
|
||||
record.ConnectionID = string.Empty;
|
||||
}
|
||||
|
||||
changedScopes[ScopeKey(record.UserID, record.ProjectID, record.BookID)] = (record.UserID, record.ProjectID, record.BookID);
|
||||
}
|
||||
|
||||
var statuses = new List<WritingWorkspaceStatus>();
|
||||
foreach (var scope in changedScopes.Values)
|
||||
{
|
||||
var wordStatus = await wordStatusForUser(scope.UserID);
|
||||
statuses.Add(BuildStatus(scope.UserID, new WritingWorkspaceScope { ProjectID = scope.ProjectID, BookID = scope.BookID }, wordStatus));
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role)
|
||||
{
|
||||
var normalRole = WritingWorkspaceRoles.Normalise(role);
|
||||
var now = DateTime.UtcNow;
|
||||
var record = ActiveWindows(userId, scope, now)
|
||||
.Where(window => string.Equals(window.WritingRole, normalRole, StringComparison.Ordinal))
|
||||
.OrderByDescending(window => window.LastSeenUtc)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(record?.ConnectionID);
|
||||
}
|
||||
|
||||
private static void Apply(WindowRecord record, string connectionId, int? projectId, int? bookId, string? route, string? role, string? displayLabel, DateTime now)
|
||||
{
|
||||
record.ConnectionID = connectionId;
|
||||
record.ProjectID = Positive(projectId);
|
||||
record.BookID = Positive(bookId);
|
||||
record.CurrentRoute = Clean(route) ?? "/";
|
||||
record.WritingRole = WritingWorkspaceRoles.Normalise(role);
|
||||
record.DisplayLabel = Clean(displayLabel);
|
||||
record.IsActive = true;
|
||||
record.LastSeenUtc = now;
|
||||
}
|
||||
|
||||
private WritingWorkspaceStatus BuildStatus(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var windows = ActiveWindows(userId, scope, now)
|
||||
.Select(ToPresence)
|
||||
.OrderBy(window => window.WritingRole)
|
||||
.ThenByDescending(window => window.LastSeenUtc)
|
||||
.ToList();
|
||||
|
||||
return new WritingWorkspaceStatus
|
||||
{
|
||||
UserID = userId,
|
||||
ProjectID = Positive(scope.ProjectID) ?? Positive(wordStatus.LinkedProjectID),
|
||||
BookID = Positive(scope.BookID) ?? Positive(wordStatus.LinkedBookID),
|
||||
WordCompanionConnected = wordStatus.IsConnected,
|
||||
WordDocumentOpen = wordStatus.DocumentOpen,
|
||||
WordProjectID = wordStatus.LinkedProjectID,
|
||||
WordBookID = wordStatus.LinkedBookID,
|
||||
Windows = windows,
|
||||
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
|
||||
Context = LatestRole(windows, WritingWorkspaceRoles.Context),
|
||||
Intent = LatestRole(windows, WritingWorkspaceRoles.Intent)
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<WindowRecord> ActiveWindows(int userId, WritingWorkspaceScope scope, DateTime now)
|
||||
{
|
||||
var projectId = Positive(scope.ProjectID);
|
||||
var bookId = Positive(scope.BookID);
|
||||
return windowsById.Values.Where(record =>
|
||||
record.UserID == userId
|
||||
&& record.IsActive
|
||||
&& now - record.LastSeenUtc <= ActiveTimeout
|
||||
&& (!projectId.HasValue || record.ProjectID == projectId)
|
||||
&& (!bookId.HasValue || record.BookID == bookId));
|
||||
}
|
||||
|
||||
private static WritingWindowPresence? LatestRole(IReadOnlyList<WritingWindowPresence> windows, string role)
|
||||
=> windows
|
||||
.Where(window => string.Equals(window.WritingRole, role, StringComparison.Ordinal))
|
||||
.OrderByDescending(window => window.LastSeenUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static WritingWindowPresence ToPresence(WindowRecord record)
|
||||
=> new()
|
||||
{
|
||||
BrowserWindowID = record.BrowserWindowID,
|
||||
UserID = record.UserID,
|
||||
ProjectID = record.ProjectID,
|
||||
BookID = record.BookID,
|
||||
CurrentRoute = record.CurrentRoute,
|
||||
WritingRole = record.WritingRole,
|
||||
DisplayLabel = record.DisplayLabel,
|
||||
IsActive = record.IsActive,
|
||||
ConnectedUtc = record.ConnectedUtc,
|
||||
LastSeenUtc = record.LastSeenUtc
|
||||
};
|
||||
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
var cleaned = (value ?? string.Empty).Trim();
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
|
||||
}
|
||||
|
||||
private static int? Positive(int? value)
|
||||
=> value is > 0 ? value.Value : null;
|
||||
|
||||
private static string WindowKey(int userId, string windowId) => $"{userId}:{windowId}";
|
||||
|
||||
private static string ScopeKey(int userId, int? projectId, int? bookId) => $"{userId}:{projectId ?? 0}:{bookId ?? 0}";
|
||||
|
||||
private sealed class WindowRecord
|
||||
{
|
||||
public string BrowserWindowID { get; init; } = string.Empty;
|
||||
public int UserID { get; init; }
|
||||
public string ConnectionID { get; set; } = string.Empty;
|
||||
public int? ProjectID { get; set; }
|
||||
public int? BookID { get; set; }
|
||||
public string CurrentRoute { get; set; } = "/";
|
||||
public string WritingRole { get; set; } = WritingWorkspaceRoles.None;
|
||||
public string? DisplayLabel { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime ConnectedUtc { get; init; }
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
}
|
||||
}
|
||||
@ -101,6 +101,17 @@
|
||||
<p>Linked to PlotDirector</p>
|
||||
</section>
|
||||
|
||||
<section class="word-companion-section word-companion-writing-workspace" aria-label="Writing workspace">
|
||||
<div class="word-companion-section-header">
|
||||
<span>Writing Workspace</span>
|
||||
</div>
|
||||
<div class="word-companion-actions">
|
||||
<button type="button" data-open-writing-workspace>Open Workspace</button>
|
||||
<button type="button" data-open-writing-context>Open Context</button>
|
||||
<button type="button" data-open-writing-intent>Open Intent</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="word-companion-tabs" aria-label="Companion sections">
|
||||
<button type="button" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button>
|
||||
<button type="button" data-tab-button="review" aria-selected="false">Review</button>
|
||||
|
||||
@ -3,6 +3,14 @@
|
||||
ViewData["Title"] = "Writer Workspace";
|
||||
var message = TempData["WriterWorkspaceMessage"] as string;
|
||||
var error = TempData["WriterWorkspaceError"] as string;
|
||||
var workspaceSeed = Model.WriteNext
|
||||
.Concat(Model.ContinueWriting)
|
||||
.Concat(Model.RevisionQueue)
|
||||
.Concat(Model.PolishingQueue)
|
||||
.FirstOrDefault();
|
||||
var workspaceProjectId = workspaceSeed?.ProjectID ?? Model.ProjectID;
|
||||
var workspaceBookId = workspaceSeed?.BookID;
|
||||
var workspaceBookTitle = workspaceSeed?.BookDisplayTitle ?? "Waiting for Word";
|
||||
}
|
||||
|
||||
<div class="page-heading writer-dashboard-heading">
|
||||
@ -42,6 +50,35 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="story-bible-section writing-workspace-launcher"
|
||||
data-writing-workspace-launcher
|
||||
data-project-id="@workspaceProjectId"
|
||||
data-book-id="@workspaceBookId">
|
||||
<div class="story-bible-section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Writing Workspace</p>
|
||||
<h2>Context | Word | Intent</h2>
|
||||
</div>
|
||||
<span class="soft-count" data-writing-workspace-book>@workspaceBookTitle</span>
|
||||
</div>
|
||||
<div class="writing-workspace-status-grid" aria-label="Writing workspace status">
|
||||
<div><span>Word Companion</span><strong data-writing-workspace-word>Checking...</strong></div>
|
||||
<div><span>Context</span><strong data-writing-workspace-context>Checking...</strong></div>
|
||||
<div><span>Intent</span><strong data-writing-workspace-intent>Checking...</strong></div>
|
||||
<div><span>Layout</span><strong data-writing-workspace-layout>Adaptive</strong></div>
|
||||
</div>
|
||||
<p class="muted writing-workspace-message" data-writing-workspace-message>
|
||||
Open a linked Word manuscript or use a queued scene to start the writing workstation.
|
||||
</p>
|
||||
<div class="button-row">
|
||||
<button type="button" class="btn btn-primary" data-start-writing-workspace>Start / Reapply Workspace</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-open-writing-context>Open Context</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-open-writing-intent>Open Intent</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-focus-writing-context hidden>Focus Context</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-focus-writing-intent hidden>Focus Intent</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="story-bible-section">
|
||||
<div class="story-bible-section-heading">
|
||||
<div>
|
||||
@ -274,3 +311,8 @@
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-workspace-launcher.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@ -19,6 +19,10 @@
|
||||
<span class="writing-context-status-dot" aria-hidden="true"></span>
|
||||
<span data-writing-context-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
|
||||
</div>
|
||||
<div class="writing-workspace-role-indicator">
|
||||
<span class="writing-context-status-dot" aria-hidden="true"></span>
|
||||
<span>Writing Workspace</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section data-writing-context-content>
|
||||
@ -27,6 +31,15 @@
|
||||
</main>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-context.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
window.PlotDirectorWritingWorkspace?.registerWindow({
|
||||
projectID: "@Model.ProjectID",
|
||||
bookID: "@Model.BookID",
|
||||
writingRole: window.PlotDirectorWritingWorkspace.roles.context,
|
||||
displayLabel: "Writing Context"
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@ -19,6 +19,10 @@
|
||||
<span class="writing-context-status-dot" aria-hidden="true"></span>
|
||||
<span data-writing-intent-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
|
||||
</div>
|
||||
<div class="writing-workspace-role-indicator">
|
||||
<span class="writing-context-status-dot" aria-hidden="true"></span>
|
||||
<span>Writing Workspace</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section data-writing-intent-content>
|
||||
@ -27,6 +31,15 @@
|
||||
</main>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-intent.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
window.PlotDirectorWritingWorkspace?.registerWindow({
|
||||
projectID: "@Model.ProjectID",
|
||||
bookID: "@Model.BookID",
|
||||
writingRole: window.PlotDirectorWritingWorkspace.roles.intent,
|
||||
displayLabel: "Writing Intent"
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@ -4200,6 +4200,57 @@ body.dragging-location [data-drag-type="location"] {
|
||||
box-shadow: 0 14px 34px rgba(36, 32, 26, 0.05);
|
||||
}
|
||||
|
||||
.writing-workspace-launcher {
|
||||
border-color: rgba(47, 111, 99, 0.22);
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(238, 245, 242, 0.68) 100%);
|
||||
}
|
||||
|
||||
.writing-workspace-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.writing-workspace-status-grid div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--plotline-line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--plotline-panel);
|
||||
}
|
||||
|
||||
.writing-workspace-status-grid span {
|
||||
color: var(--plotline-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.writing-workspace-status-grid strong {
|
||||
color: var(--plotline-ink);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.writing-workspace-message {
|
||||
min-height: 1.35rem;
|
||||
}
|
||||
|
||||
.writing-workspace-role-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid rgba(47, 111, 99, 0.22);
|
||||
border-radius: 999px;
|
||||
background: var(--plotline-soft);
|
||||
color: var(--plotline-accent-dark);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.writer-queue-card {
|
||||
border: 1px solid var(--plotline-line);
|
||||
border-radius: 12px;
|
||||
@ -4659,6 +4710,10 @@ body.dragging-location [data-drag-type="location"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.writing-workspace-status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.writer-checklist-row,
|
||||
.writer-add-inline {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -662,6 +662,10 @@ body {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.word-companion-writing-workspace .word-companion-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.word-companion-message:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
File diff suppressed because one or more lines are too long
@ -86,6 +86,9 @@
|
||||
const unlinkDialogSceneCount = document.querySelector("[data-unlink-dialog-scene-count]");
|
||||
const cleanDialogChapterCount = document.querySelector("[data-clean-dialog-chapter-count]");
|
||||
const cleanDialogSceneCount = document.querySelector("[data-clean-dialog-scene-count]");
|
||||
const openWritingWorkspaceButton = document.querySelector("[data-open-writing-workspace]");
|
||||
const openWritingContextButton = document.querySelector("[data-open-writing-context]");
|
||||
const openWritingIntentButton = document.querySelector("[data-open-writing-intent]");
|
||||
const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]");
|
||||
const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]");
|
||||
const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]");
|
||||
@ -1349,6 +1352,45 @@
|
||||
return Number.isInteger(bookId) && bookId > 0 ? bookId : null;
|
||||
};
|
||||
|
||||
const writingWorkspaceScope = () => ({
|
||||
projectId: selectedPresenceProjectId(),
|
||||
bookId: selectedPresenceBookId()
|
||||
});
|
||||
|
||||
const writingWindowName = (role) => {
|
||||
const scope = writingWorkspaceScope();
|
||||
const suffix = scope.bookId ? `Book_${scope.bookId}` : scope.projectId ? `Project_${scope.projectId}` : "General";
|
||||
return `PlotDirector${role}_${suffix}`;
|
||||
};
|
||||
|
||||
const writingWorkspaceUrl = (target) => {
|
||||
const scope = writingWorkspaceScope();
|
||||
const url = new URL(target === "workspace" ? "/Writer" : `/Writing/${target}`, window.location.origin);
|
||||
if (scope.projectId) {
|
||||
url.searchParams.set("projectId", scope.projectId);
|
||||
}
|
||||
if (scope.bookId && target !== "workspace") {
|
||||
url.searchParams.set("bookId", scope.bookId);
|
||||
}
|
||||
if (detectedChapterAnchorId && target !== "workspace") {
|
||||
url.searchParams.set("chapterId", detectedChapterAnchorId);
|
||||
}
|
||||
if (detectedSceneAnchorId && target !== "workspace") {
|
||||
url.searchParams.set("sceneId", detectedSceneAnchorId);
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
};
|
||||
|
||||
const openWritingWindow = (target) => {
|
||||
const role = target === "context" ? "Context" : target === "intent" ? "Intent" : "Workspace";
|
||||
const opened = window.open(writingWorkspaceUrl(target), writingWindowName(role));
|
||||
if (opened) {
|
||||
opened.focus();
|
||||
} else {
|
||||
setDocumentMessage("Your browser blocked the writing workspace window. Use the browser pop-up prompt to allow PlotDirector windows.");
|
||||
}
|
||||
};
|
||||
|
||||
const currentDocumentName = () => {
|
||||
const url = String(window.Office?.context?.document?.url || "").trim();
|
||||
if (!url) {
|
||||
@ -5988,6 +6030,9 @@
|
||||
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
|
||||
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
|
||||
cleanUnlinkWordDocumentButton?.addEventListener("click", cleanAndUnlinkWordDocument);
|
||||
openWritingWorkspaceButton?.addEventListener("click", () => openWritingWindow("workspace"));
|
||||
openWritingContextButton?.addEventListener("click", () => openWritingWindow("context"));
|
||||
openWritingIntentButton?.addEventListener("click", () => openWritingWindow("intent"));
|
||||
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
|
||||
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
|
||||
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -48,6 +48,25 @@
|
||||
}
|
||||
};
|
||||
|
||||
const registerWorkspaceRole = () => {
|
||||
const role = config.source === "WritingContext"
|
||||
? window.PlotDirectorWritingWorkspace?.roles?.context
|
||||
: config.source === "WritingIntent"
|
||||
? window.PlotDirectorWritingWorkspace?.roles?.intent
|
||||
: null;
|
||||
if (!role || !window.PlotDirectorWritingWorkspace?.registerWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = contentHost?.querySelector(config.fragmentSelector);
|
||||
void window.PlotDirectorWritingWorkspace.registerWindow({
|
||||
projectID: fragment?.dataset.projectId || root.dataset.scopeProjectId,
|
||||
bookID: fragment?.dataset.bookId || root.dataset.scopeBookId,
|
||||
writingRole: role,
|
||||
displayLabel: config.source === "WritingContext" ? "Writing Context" : "Writing Intent"
|
||||
}).catch(() => null);
|
||||
};
|
||||
|
||||
const readStoredTab = () => {
|
||||
try {
|
||||
return localStorage.getItem(config.storageKey) || config.defaultTab;
|
||||
@ -87,6 +106,7 @@
|
||||
if (fragmentStatus) {
|
||||
setStatus(fragmentStatus);
|
||||
}
|
||||
registerWorkspaceRole();
|
||||
|
||||
const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId);
|
||||
if (fragmentSceneId) {
|
||||
|
||||
123
PlotLine/wwwroot/js/writing-workspace-launcher.js
Normal file
123
PlotLine/wwwroot/js/writing-workspace-launcher.js
Normal file
@ -0,0 +1,123 @@
|
||||
(() => {
|
||||
const root = document.querySelector("[data-writing-workspace-launcher]");
|
||||
const workspace = window.PlotDirectorWritingWorkspace;
|
||||
if (!root || !workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusNodes = {
|
||||
book: root.querySelector("[data-writing-workspace-book]"),
|
||||
word: root.querySelector("[data-writing-workspace-word]"),
|
||||
context: root.querySelector("[data-writing-workspace-context]"),
|
||||
intent: root.querySelector("[data-writing-workspace-intent]"),
|
||||
layout: root.querySelector("[data-writing-workspace-layout]"),
|
||||
message: root.querySelector("[data-writing-workspace-message]")
|
||||
};
|
||||
const startButton = root.querySelector("[data-start-writing-workspace]");
|
||||
const contextButton = root.querySelector("[data-open-writing-context]");
|
||||
const intentButton = root.querySelector("[data-open-writing-intent]");
|
||||
const focusContextButton = root.querySelector("[data-focus-writing-context]");
|
||||
const focusIntentButton = root.querySelector("[data-focus-writing-intent]");
|
||||
const fallbackScope = workspace.scopeFrom({
|
||||
projectID: root.dataset.projectId,
|
||||
bookID: root.dataset.bookId
|
||||
});
|
||||
let activeScope = { ...fallbackScope };
|
||||
let latestStatus = null;
|
||||
|
||||
const text = (node, value) => {
|
||||
if (node) node.textContent = value;
|
||||
};
|
||||
|
||||
const statusText = (status, role) => {
|
||||
const match = (status?.windows || []).find((windowPresence) =>
|
||||
String(windowPresence.writingRole || windowPresence.WritingRole || "").toLowerCase() === role.toLowerCase()
|
||||
&& (!activeScope.bookID || Number(windowPresence.bookID || windowPresence.BookID) === activeScope.bookID));
|
||||
if (!match) return "Not open";
|
||||
const id = match.browserWindowID || match.BrowserWindowID || "";
|
||||
return id === workspace.getBrowserWindowId() ? "This window" : "Open";
|
||||
};
|
||||
|
||||
const setMessage = (message) => text(statusNodes.message, message || "");
|
||||
|
||||
const render = (status = latestStatus) => {
|
||||
latestStatus = status;
|
||||
activeScope = workspace.statusScope(status, activeScope);
|
||||
text(statusNodes.book, activeScope.bookID ? `Book ${activeScope.bookID}` : "Waiting for book");
|
||||
text(statusNodes.word, status?.wordCompanionConnected || status?.WordCompanionConnected ? "Connected" : "Not connected");
|
||||
text(statusNodes.context, statusText(status, "Context"));
|
||||
text(statusNodes.intent, statusText(status, "Intent"));
|
||||
const contextOpen = statusText(status, "Context") !== "Not open";
|
||||
const intentOpen = statusText(status, "Intent") !== "Not open";
|
||||
if (focusContextButton) focusContextButton.hidden = !contextOpen;
|
||||
if (focusIntentButton) focusIntentButton.hidden = !intentOpen;
|
||||
if (contextButton) contextButton.textContent = contextOpen ? "Focus Context" : "Open Context";
|
||||
if (intentButton) intentButton.textContent = intentOpen ? "Focus Intent" : "Open Intent";
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
render(await workspace.watchWorkspace(activeScope));
|
||||
} catch (error) {
|
||||
setMessage("Writing workspace presence is unavailable.");
|
||||
console.debug("Writing workspace presence unavailable.", error);
|
||||
}
|
||||
};
|
||||
|
||||
const openOrFocus = async (role) => {
|
||||
const scope = workspace.statusScope(latestStatus, activeScope);
|
||||
if (!scope.bookID) {
|
||||
setMessage("Open a linked Word manuscript or focus a book before opening writing windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
await workspace.focusRole(role, scope);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
startButton?.addEventListener("click", async () => {
|
||||
startButton.disabled = true;
|
||||
setMessage("Starting writing workspace...");
|
||||
try {
|
||||
const result = await workspace.startWorkspace(activeScope, { requestScreens: true });
|
||||
if (!result.ok) {
|
||||
setMessage(result.message || `Pop-up blocked for ${result.blocked?.join(", ") || "a writing window"}. Use the explicit open buttons.`);
|
||||
} else if (result.reusedCurrentWindowAs) {
|
||||
setMessage("Reusing this window for Writing Intent and opening Context.");
|
||||
} else {
|
||||
setMessage("Writing workspace ready.");
|
||||
}
|
||||
text(statusNodes.layout, result.screenInfo?.layout?.mode || "single-screen");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.debug("Unable to start writing workspace.", error);
|
||||
setMessage("Writing workspace could not be started. Use the direct open buttons.");
|
||||
} finally {
|
||||
startButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
contextButton?.addEventListener("click", () => openOrFocus(workspace.roles.context));
|
||||
intentButton?.addEventListener("click", () => openOrFocus(workspace.roles.intent));
|
||||
focusContextButton?.addEventListener("click", () => openOrFocus(workspace.roles.context));
|
||||
focusIntentButton?.addEventListener("click", () => openOrFocus(workspace.roles.intent));
|
||||
|
||||
document.addEventListener("plotdirector:writing-workspace-status", (event) => render(event.detail));
|
||||
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
|
||||
const position = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
|
||||
if (position?.projectId && position?.bookId) {
|
||||
activeScope = { projectID: position.projectId, bookID: position.bookId };
|
||||
void workspace.registerWindow({
|
||||
...activeScope,
|
||||
writingRole: workspace.roles.launcher,
|
||||
displayLabel: "Writing Workspace"
|
||||
}).then(render).catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
void workspace.registerWindow({
|
||||
...activeScope,
|
||||
writingRole: workspace.roles.launcher,
|
||||
displayLabel: "Writing Workspace"
|
||||
}).then(render).catch(() => null);
|
||||
})();
|
||||
349
PlotLine/wwwroot/js/writing-workspace.js
Normal file
349
PlotLine/wwwroot/js/writing-workspace.js
Normal file
@ -0,0 +1,349 @@
|
||||
(() => {
|
||||
const storageKeys = {
|
||||
windowId: "plotdirector.browserWindowId",
|
||||
preferences: "plotdirector.writingWorkspace.preferences"
|
||||
};
|
||||
const roles = {
|
||||
none: "None",
|
||||
launcher: "Launcher",
|
||||
context: "Context",
|
||||
intent: "Intent"
|
||||
};
|
||||
const windowRefs = new Map();
|
||||
let connection = null;
|
||||
let connectionPromise = null;
|
||||
let heartbeatTimer = null;
|
||||
let registration = null;
|
||||
let latestStatus = null;
|
||||
let latestPosition = null;
|
||||
|
||||
const parsePositiveInt = (value) => {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const getBrowserWindowId = () => {
|
||||
try {
|
||||
let value = sessionStorage.getItem(storageKeys.windowId);
|
||||
if (!value) {
|
||||
value = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
sessionStorage.setItem(storageKeys.windowId, value);
|
||||
}
|
||||
return value;
|
||||
} catch {
|
||||
return window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanRole = (role) => {
|
||||
const value = String(role || "").toLowerCase();
|
||||
return value === "launcher" ? roles.launcher
|
||||
: value === "context" ? roles.context
|
||||
: value === "intent" ? roles.intent
|
||||
: roles.none;
|
||||
};
|
||||
|
||||
const scopeFrom = (source = {}) => ({
|
||||
projectID: parsePositiveInt(source.projectID ?? source.projectId ?? source.ProjectID ?? source.ProjectId),
|
||||
bookID: parsePositiveInt(source.bookID ?? source.bookId ?? source.BookID ?? source.BookId)
|
||||
});
|
||||
|
||||
const scopeMatches = (windowPresence, scope) => {
|
||||
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
|
||||
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
|
||||
return (!projectId || parsePositiveInt(windowPresence?.projectID ?? windowPresence?.projectId) === projectId)
|
||||
&& (!bookId || parsePositiveInt(windowPresence?.bookID ?? windowPresence?.bookId) === bookId);
|
||||
};
|
||||
|
||||
const roleWindow = (status, role, scope) => (status?.windows || [])
|
||||
.filter((windowPresence) => cleanRole(windowPresence.writingRole ?? windowPresence.WritingRole) === role && scopeMatches(windowPresence, scope))
|
||||
.sort((a, b) => Date.parse(b.lastSeenUtc || b.LastSeenUtc || 0) - Date.parse(a.lastSeenUtc || a.LastSeenUtc || 0))[0] || null;
|
||||
|
||||
const currentRoute = () => `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
const statusScope = (status, fallback = {}) => ({
|
||||
projectID: parsePositiveInt(status?.projectID ?? status?.projectId ?? fallback.projectID ?? fallback.projectId),
|
||||
bookID: parsePositiveInt(status?.bookID ?? status?.bookId ?? fallback.bookID ?? fallback.bookId)
|
||||
});
|
||||
|
||||
const buildWritingUrl = (role, scope) => {
|
||||
const url = new URL(role === roles.context ? "/Writing/Context" : "/Writing/Intent", window.location.origin);
|
||||
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
|
||||
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
|
||||
const position = latestPosition || {};
|
||||
if (projectId) url.searchParams.set("projectId", projectId);
|
||||
if (bookId) url.searchParams.set("bookId", bookId);
|
||||
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
|
||||
if (position.sceneId) url.searchParams.set("sceneId", position.sceneId);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
const stableWindowName = (role, scope) => {
|
||||
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
|
||||
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
|
||||
const suffix = bookId ? `Book_${bookId}` : projectId ? `Project_${projectId}` : "General";
|
||||
return `PlotDirector${role}_${suffix}`.replace(/[^A-Za-z0-9_]/g, "_");
|
||||
};
|
||||
|
||||
const readPreferences = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storageKeys.preferences) || "{}") || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = (preferences) => {
|
||||
try {
|
||||
localStorage.setItem(storageKeys.preferences, JSON.stringify({ ...readPreferences(), ...preferences }));
|
||||
} catch {
|
||||
// Layout memory is a convenience only.
|
||||
}
|
||||
};
|
||||
|
||||
const sortScreens = (screens) => [...(screens || [])]
|
||||
.map((screen, index) => ({
|
||||
index,
|
||||
left: Number(screen.availLeft ?? screen.left ?? 0),
|
||||
top: Number(screen.availTop ?? screen.top ?? 0),
|
||||
width: Math.max(320, Number(screen.availWidth ?? screen.width ?? 1200)),
|
||||
height: Math.max(320, Number(screen.availHeight ?? screen.height ?? 800)),
|
||||
isPrimary: Boolean(screen.isPrimary),
|
||||
isInternal: Boolean(screen.isInternal),
|
||||
label: screen.label || (screen.isPrimary ? "Primary display" : `Display ${index + 1}`)
|
||||
}))
|
||||
.sort((a, b) => a.left - b.left || a.top - b.top || a.index - b.index);
|
||||
|
||||
const chooseLayout = (screens, preferences = {}) => {
|
||||
const ordered = sortScreens(screens);
|
||||
if (ordered.length >= 3) {
|
||||
return {
|
||||
mode: "three-screen",
|
||||
contextDisplay: preferences.contextDisplay === "right" ? ordered[ordered.length - 1] : ordered[0],
|
||||
intentDisplay: preferences.intentDisplay === "left" ? ordered[0] : ordered[ordered.length - 1],
|
||||
wordDisplay: ordered[Math.floor(ordered.length / 2)],
|
||||
displayCount: ordered.length
|
||||
};
|
||||
}
|
||||
|
||||
if (ordered.length === 2) {
|
||||
const primaryRole = preferences.twoScreenPrimary === "intent" ? roles.intent : roles.context;
|
||||
const secondary = ordered.find((screen) => !screen.isPrimary) || ordered[1];
|
||||
return {
|
||||
mode: "two-screen",
|
||||
contextDisplay: primaryRole === roles.context ? secondary : ordered[0],
|
||||
intentDisplay: primaryRole === roles.intent ? secondary : ordered[0],
|
||||
wordDisplay: ordered.find((screen) => screen.isPrimary) || ordered[0],
|
||||
displayCount: ordered.length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "single-screen",
|
||||
contextDisplay: ordered[0] || null,
|
||||
intentDisplay: ordered[0] || null,
|
||||
wordDisplay: ordered[0] || null,
|
||||
displayCount: ordered.length
|
||||
};
|
||||
};
|
||||
|
||||
const popupFeatures = (display) => {
|
||||
if (!display) {
|
||||
return "popup=yes,width=1120,height=900";
|
||||
}
|
||||
|
||||
const width = Math.max(900, Math.floor(display.width * 0.92));
|
||||
const height = Math.max(760, Math.floor(display.height * 0.92));
|
||||
const left = Math.floor(display.left + (display.width - width) / 2);
|
||||
const top = Math.floor(display.top + (display.height - height) / 2);
|
||||
return `popup=yes,left=${left},top=${top},width=${width},height=${height}`;
|
||||
};
|
||||
|
||||
const getScreenLayout = async (requestPermission) => {
|
||||
if (!("getScreenDetails" in window) || typeof window.getScreenDetails !== "function") {
|
||||
return { supported: false, permission: "unavailable", layout: chooseLayout([], readPreferences()) };
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.permissions?.query && !requestPermission) {
|
||||
const permission = await navigator.permissions.query({ name: "window-management" });
|
||||
if (permission.state !== "granted") {
|
||||
return { supported: true, permission: permission.state, layout: chooseLayout([], readPreferences()) };
|
||||
}
|
||||
}
|
||||
|
||||
const details = await window.getScreenDetails();
|
||||
const screens = sortScreens(details.screens || []);
|
||||
return { supported: true, permission: "granted", screens, currentScreen: details.currentScreen, layout: chooseLayout(screens, readPreferences()) };
|
||||
} catch (error) {
|
||||
return { supported: true, permission: error?.name === "NotAllowedError" ? "denied" : "unavailable", error, layout: chooseLayout([], readPreferences()) };
|
||||
}
|
||||
};
|
||||
|
||||
const ensureConnection = async () => {
|
||||
if (!window.signalR) {
|
||||
throw new Error("SignalR is unavailable.");
|
||||
}
|
||||
if (connectionPromise) {
|
||||
return connectionPromise;
|
||||
}
|
||||
connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/word-companion-follow")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on("WritingWorkspacePresenceChanged", (status) => {
|
||||
latestStatus = status;
|
||||
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: status }));
|
||||
});
|
||||
connection.on("WordCompanionPresenceChanged", (status) => {
|
||||
latestStatus = latestStatus ? { ...latestStatus, wordCompanionConnected: Boolean(status?.isConnected ?? status?.IsConnected), wordDocumentOpen: Boolean(status?.documentOpen ?? status?.DocumentOpen) } : latestStatus;
|
||||
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-word-status", { bubbles: true, detail: status }));
|
||||
});
|
||||
connection.on("WritingWorkspaceFocusRequested", () => {
|
||||
try {
|
||||
window.focus();
|
||||
} catch {
|
||||
// Browser focus restrictions are expected here.
|
||||
}
|
||||
});
|
||||
connection.onreconnected(() => {
|
||||
if (registration) {
|
||||
void registerWindow(registration);
|
||||
}
|
||||
});
|
||||
|
||||
connectionPromise = connection.start().then(() => connection);
|
||||
return connectionPromise;
|
||||
};
|
||||
|
||||
const invoke = async (method, ...args) => {
|
||||
const activeConnection = await ensureConnection();
|
||||
return await activeConnection.invoke(method, ...args);
|
||||
};
|
||||
|
||||
const registerWindow = async (options) => {
|
||||
registration = {
|
||||
browserWindowID: getBrowserWindowId(),
|
||||
projectID: parsePositiveInt(options.projectID ?? options.projectId),
|
||||
bookID: parsePositiveInt(options.bookID ?? options.bookId),
|
||||
currentRoute: currentRoute(),
|
||||
writingRole: cleanRole(options.writingRole || options.role),
|
||||
displayLabel: options.displayLabel || ""
|
||||
};
|
||||
latestStatus = await invoke("RegisterWritingWindow", registration);
|
||||
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
|
||||
if (heartbeatTimer) {
|
||||
window.clearInterval(heartbeatTimer);
|
||||
}
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
if (!registration) return;
|
||||
void invoke("WritingWindowHeartbeat", { ...registration, currentRoute: currentRoute() }).catch(() => null);
|
||||
}, 15000);
|
||||
return latestStatus;
|
||||
};
|
||||
|
||||
const watchWorkspace = async (scope) => {
|
||||
latestStatus = await invoke("WatchWritingWorkspace", scopeFrom(scope));
|
||||
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
|
||||
return latestStatus;
|
||||
};
|
||||
|
||||
const openRoleWindow = (role, scope, display) => {
|
||||
const url = buildWritingUrl(role, scope);
|
||||
const name = stableWindowName(role, scope);
|
||||
const opened = window.open(url, name, popupFeatures(display));
|
||||
if (opened) {
|
||||
windowRefs.set(role, opened);
|
||||
try {
|
||||
opened.focus();
|
||||
} catch {
|
||||
// Focus is best-effort only.
|
||||
}
|
||||
}
|
||||
return opened;
|
||||
};
|
||||
|
||||
const focusRole = async (role, scope) => {
|
||||
const ref = windowRefs.get(role);
|
||||
if (ref && !ref.closed) {
|
||||
ref.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
const opened = window.open(buildWritingUrl(role, scope), stableWindowName(role, scope));
|
||||
if (opened) {
|
||||
windowRefs.set(role, opened);
|
||||
opened.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
await invoke("RequestWritingWindowFocus", scopeFrom(scope), role).catch(() => null);
|
||||
return false;
|
||||
};
|
||||
|
||||
const startWorkspace = async (scope, options = {}) => {
|
||||
const cleanScope = scopeFrom(scope);
|
||||
const status = await watchWorkspace(cleanScope);
|
||||
const resolvedScope = statusScope(status, cleanScope);
|
||||
if (!resolvedScope.projectID || !resolvedScope.bookID) {
|
||||
return { ok: false, message: "Choose a book or open a linked Word manuscript before starting the writing workspace.", status };
|
||||
}
|
||||
|
||||
const screenInfo = await getScreenLayout(Boolean(options.requestScreens));
|
||||
const layout = screenInfo.layout;
|
||||
const existingContext = roleWindow(status, roles.context, resolvedScope);
|
||||
const existingIntent = roleWindow(status, roles.intent, resolvedScope);
|
||||
const currentRole = cleanRole(registration?.writingRole);
|
||||
const opened = [];
|
||||
const blocked = [];
|
||||
|
||||
if (!existingIntent && currentRole !== roles.context && currentRole !== roles.intent) {
|
||||
savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode });
|
||||
if (!existingContext) {
|
||||
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
|
||||
(contextWindow ? opened : blocked).push(roles.context);
|
||||
}
|
||||
window.location.assign(buildWritingUrl(roles.intent, resolvedScope));
|
||||
return { ok: true, reusedCurrentWindowAs: roles.intent, opened, blocked, status, screenInfo };
|
||||
}
|
||||
|
||||
if (!existingContext) {
|
||||
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
|
||||
(contextWindow ? opened : blocked).push(roles.context);
|
||||
} else {
|
||||
await focusRole(roles.context, resolvedScope);
|
||||
}
|
||||
|
||||
if (!existingIntent) {
|
||||
const intentWindow = currentRole === roles.intent ? window : openRoleWindow(roles.intent, resolvedScope, layout.intentDisplay);
|
||||
(intentWindow ? opened : blocked).push(roles.intent);
|
||||
} else {
|
||||
await focusRole(roles.intent, resolvedScope);
|
||||
}
|
||||
|
||||
savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode });
|
||||
return { ok: blocked.length === 0, opened, blocked, status: await watchWorkspace(resolvedScope), screenInfo };
|
||||
};
|
||||
|
||||
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
|
||||
latestPosition = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
|
||||
});
|
||||
|
||||
window.PlotDirectorWritingWorkspace = {
|
||||
roles,
|
||||
getBrowserWindowId,
|
||||
registerWindow,
|
||||
watchWorkspace,
|
||||
startWorkspace,
|
||||
focusRole,
|
||||
openRoleWindow,
|
||||
stableWindowName,
|
||||
buildWritingUrl,
|
||||
sortScreens,
|
||||
chooseLayout,
|
||||
getScreenLayout,
|
||||
scopeFrom,
|
||||
statusScope
|
||||
};
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user