Add paired writing intent follow screen
This commit is contained in:
parent
fa2ffe4f17
commit
884a96d166
@ -76,7 +76,11 @@ var tests = new (string Name, Action Test)[]
|
||||
("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity),
|
||||
("Writing Context route uses canonical story context service", WritingContextRouteUsesCanonicalStoryContextService),
|
||||
("Writing Context client follows canonical story position events", WritingContextClientFollowsCanonicalStoryPositionEvents),
|
||||
("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops)
|
||||
("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops),
|
||||
("Writing Intent route uses canonical story context and planning data", WritingIntentRouteUsesCanonicalStoryContextAndPlanningData),
|
||||
("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)
|
||||
};
|
||||
|
||||
foreach (var test in tests)
|
||||
@ -157,29 +161,109 @@ static void WritingContextRouteUsesCanonicalStoryContextService()
|
||||
static void WritingContextClientFollowsCanonicalStoryPositionEvents()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
|
||||
|
||||
Assert(view.Contains("data-writing-context", StringComparison.Ordinal), "Writing Context shell should expose a stable script root.");
|
||||
Assert(view.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should identify itself as a writing-mode page.");
|
||||
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Context should load the shared writing follow helper.");
|
||||
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should initialize the shared writing follow helper.");
|
||||
Assert(script.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Context must subscribe to canonical browser story-position events.");
|
||||
Assert(script.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing Context must bridge SignalR story-position events.");
|
||||
Assert(script.Contains("fetch(url", StringComparison.Ordinal), "Writing Context should refresh context content without full page navigation.");
|
||||
Assert(script.Contains("data-writing-context-scene-select", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired.");
|
||||
Assert(initializer.Contains("[data-writing-context-scene-select]", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired.");
|
||||
Assert(script.Contains("publishChanged", StringComparison.Ordinal), "Manual scene selection should publish canonical story-position changes.");
|
||||
}
|
||||
|
||||
static void WritingContextPreservesSelectedTabAndAvoidsEventLoops()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
||||
|
||||
Assert(script.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab.");
|
||||
Assert(initializer.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab.");
|
||||
Assert(script.Contains("activateTab(tab)", StringComparison.Ordinal), "Writing Context should restore the selected tab after content refresh.");
|
||||
Assert(script.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate current-scene events.");
|
||||
Assert(script.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate in-flight scene events.");
|
||||
Assert(!script.Contains("window.location.assign", StringComparison.Ordinal) && !script.Contains("window.location.href", StringComparison.Ordinal), "Writing Context must not navigate away when Word changes scene.");
|
||||
}
|
||||
|
||||
static void WritingIntentRouteUsesCanonicalStoryContextAndPlanningData()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
|
||||
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
|
||||
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
|
||||
|
||||
Assert(controller.Contains("[HttpGet(\"Intent\")", StringComparison.Ordinal), "Writing Intent shell route is missing.");
|
||||
Assert(controller.Contains("[HttpGet(\"Intent/Scene\")", StringComparison.Ordinal), "Writing Intent partial route is missing.");
|
||||
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Intent must share canonical story context service injection.");
|
||||
Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Intent scene requests must use canonical story context.");
|
||||
Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should reuse existing writer planning data.");
|
||||
Assert(controller.Contains("writerWorkspace.GetChapterWorkflowAsync", StringComparison.Ordinal), "Writing Intent should load chapter workflow planning data.");
|
||||
Assert(controller.Contains("projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched project scope.");
|
||||
Assert(controller.Contains("bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched book scope.");
|
||||
Assert(viewModel.Contains("public sealed class WritingIntentDisplayViewModel", StringComparison.Ordinal), "Writing Intent display model is missing.");
|
||||
Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Intent display should carry canonical story context.");
|
||||
Assert(viewModel.Contains("ChapterWorkflowViewModel? ChapterWorkflow", StringComparison.Ordinal), "Writing Intent display should carry chapter planning workflow.");
|
||||
Assert(contentView.Contains("No current scene. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should support chapter-only fallback.");
|
||||
Assert(contentView.Contains("No scene-specific plan. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should prefer chapter intent when scene planning is sparse.");
|
||||
}
|
||||
|
||||
static void WritingIntentClientUsesSharedWritingFollowHelper()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
|
||||
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
|
||||
|
||||
Assert(view.Contains("data-writing-intent", StringComparison.Ordinal), "Writing Intent shell should expose a stable script root.");
|
||||
Assert(view.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should identify itself as a writing-mode page.");
|
||||
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Intent should load the shared writing follow helper.");
|
||||
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should initialize the shared helper.");
|
||||
Assert(initializer.Contains("plotdirector.writingIntent.activeTab", StringComparison.Ordinal), "Writing Intent should remember the selected tab.");
|
||||
Assert(initializer.Contains("defaultEndpoint: \"/Writing/Intent/Scene\"", StringComparison.Ordinal), "Writing Intent should refresh from its partial route.");
|
||||
Assert(initializer.Contains("allowChapterOnly: true", StringComparison.Ordinal), "Writing Intent should accept canonical chapter-only positions.");
|
||||
Assert(helper.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Intent should receive canonical story-position events through the shared helper.");
|
||||
Assert(helper.Contains("fetch(url", StringComparison.Ordinal), "Writing Intent should update in place.");
|
||||
Assert(helper.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate current-scene events.");
|
||||
Assert(helper.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate in-flight events.");
|
||||
Assert(helper.Contains("position.bookId && position.bookId !== scopedBookId", StringComparison.Ordinal), "Writing Intent should ignore unrelated scoped book events.");
|
||||
Assert(helper.Contains("publishChanged", StringComparison.Ordinal), "Writing Intent manual fallback should publish canonical story-position changes.");
|
||||
Assert(contentView.Contains("data-writing-intent-scene-select", StringComparison.Ordinal), "Writing Intent manual scene selector should be wired.");
|
||||
Assert(contentView.Contains("data-writing-intent-chapter-select", StringComparison.Ordinal), "Writing Intent manual chapter selector should be wired.");
|
||||
Assert(!helper.Contains("window.location.assign", StringComparison.Ordinal) && !helper.Contains("window.location.href", StringComparison.Ordinal), "Writing Intent must not navigate away when Word changes scene.");
|
||||
}
|
||||
|
||||
static void WritingModePagesAreIsolatedFromGlobalWordNavigation()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var site = File.ReadAllText(Path.Combine(root, "wwwroot/js/site.js"));
|
||||
|
||||
Assert(site.Contains("document.querySelector(\"[data-writing-mode]\")", StringComparison.Ordinal), "Global Word follow should recognize writing-mode markers.");
|
||||
Assert(site.Contains("path === \"/writing/context\"", StringComparison.Ordinal), "Writing Context route should be exempt from global scene navigation.");
|
||||
Assert(site.Contains("path === \"/writing/intent\"", StringComparison.Ordinal), "Writing Intent route should be exempt from global scene navigation.");
|
||||
Assert(site.Contains("window.location.assign(url)", StringComparison.Ordinal), "Normal pages should retain existing Word follow navigation.");
|
||||
Assert(site.Contains("safeRelativeUrl(payload?.sceneUrl || payload?.SceneUrl || \"\", \"/Scenes/Edit/\")", StringComparison.Ordinal), "Normal scene follow should still target canonical Scene Edit URLs.");
|
||||
}
|
||||
|
||||
static void WritingPairSharesCanonicalFollowInfrastructure()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var context = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
|
||||
var intent = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
|
||||
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
|
||||
|
||||
Assert(context.Contains("source: \"WritingContext\"", StringComparison.Ordinal), "Writing Context should identify its canonical event source.");
|
||||
Assert(intent.Contains("source: \"WritingIntent\"", StringComparison.Ordinal), "Writing Intent should identify its canonical event source.");
|
||||
Assert(context.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should use shared follow initialization.");
|
||||
Assert(intent.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should use shared follow initialization.");
|
||||
Assert(helper.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "The shared helper should bridge Word story-position events for both pages.");
|
||||
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
|
||||
}
|
||||
|
||||
static void StripsMarkdownFence()
|
||||
{
|
||||
var result = StoryIntelligenceJsonRepair.Repair("""
|
||||
|
||||
@ -12,7 +12,8 @@ namespace PlotLine.Controllers;
|
||||
public sealed class WritingController(
|
||||
IStoryContextService storyContext,
|
||||
IRelationshipMapService relationshipMap,
|
||||
IFloorPlanService floorPlans) : Controller
|
||||
IFloorPlanService floorPlans,
|
||||
IWriterWorkspaceService writerWorkspace) : Controller
|
||||
{
|
||||
[HttpGet("Context")]
|
||||
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId)
|
||||
@ -31,6 +32,23 @@ public sealed class WritingController(
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpGet("Intent")]
|
||||
public async Task<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId)
|
||||
{
|
||||
var model = new WritingIntentPageViewModel
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
ChapterID = chapterId,
|
||||
SceneID = sceneId,
|
||||
Display = sceneId.HasValue || chapterId.HasValue
|
||||
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
|
||||
: new WritingIntentDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
|
||||
};
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpGet("Context/Scene")]
|
||||
public async Task<IActionResult> ContextScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
|
||||
{
|
||||
@ -48,6 +66,23 @@ public sealed class WritingController(
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("Intent/Scene")]
|
||||
public async Task<IActionResult> IntentScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
|
||||
{
|
||||
if (!sceneId.HasValue && !chapterId.HasValue)
|
||||
{
|
||||
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", new WritingIntentDisplayViewModel
|
||||
{
|
||||
FollowStatus = "Connected, waiting for scene"
|
||||
});
|
||||
}
|
||||
|
||||
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
|
||||
return model is not null && (model.HasScene || model.HasChapter)
|
||||
? PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model)
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
|
||||
{
|
||||
if (!sceneId.HasValue)
|
||||
@ -77,6 +112,52 @@ public sealed class WritingController(
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<WritingIntentDisplayViewModel?> BuildIntentDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
|
||||
{
|
||||
StoryContextViewModel? context = null;
|
||||
if (sceneId.HasValue)
|
||||
{
|
||||
context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
|
||||
if (context?.Scene is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedChapterId = context?.Position.ChapterId ?? chapterId;
|
||||
var chapterWorkflow = resolvedChapterId.HasValue
|
||||
? await writerWorkspace.GetChapterWorkflowAsync(resolvedChapterId.Value)
|
||||
: null;
|
||||
|
||||
if (chapterWorkflow is not null)
|
||||
{
|
||||
if (projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (context is null && chapterWorkflow is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WritingIntentDisplayViewModel
|
||||
{
|
||||
Context = context,
|
||||
ChapterWorkflow = chapterWorkflow,
|
||||
FollowStatus = followStatus,
|
||||
SceneOptions = context?.Scene is not null
|
||||
? BuildSceneOptions(context.Scene)
|
||||
: BuildSceneOptions(chapterWorkflow)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SelectListItem> BuildSceneOptions(SceneEditViewModel scene)
|
||||
{
|
||||
var options = scene.SceneOptions
|
||||
@ -96,4 +177,15 @@ public sealed class WritingController(
|
||||
.ThenBy(x => x.Text)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SelectListItem> BuildSceneOptions(ChapterWorkflowViewModel? chapterWorkflow)
|
||||
{
|
||||
return chapterWorkflow?.Scenes
|
||||
.Select(x => new SelectListItem(
|
||||
$"Scene {x.SceneNumber:g}: {x.SceneTitle}",
|
||||
x.SceneID.ToString()))
|
||||
.OrderBy(x => x.Text)
|
||||
.ToList()
|
||||
?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -448,6 +448,26 @@ public sealed class WritingContextDisplayViewModel
|
||||
public bool HasScene => Context?.Scene is not null;
|
||||
}
|
||||
|
||||
public sealed class WritingIntentPageViewModel
|
||||
{
|
||||
public int? ProjectID { get; set; }
|
||||
public int? BookID { get; set; }
|
||||
public int? ChapterID { get; set; }
|
||||
public int? SceneID { get; set; }
|
||||
public WritingIntentDisplayViewModel? Display { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WritingIntentDisplayViewModel
|
||||
{
|
||||
public StoryContextViewModel? Context { get; set; }
|
||||
public ChapterWorkflowViewModel? ChapterWorkflow { get; set; }
|
||||
public string FollowStatus { get; set; } = "Connected, waiting for scene";
|
||||
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
|
||||
public bool HasScene => Context?.Scene is not null;
|
||||
public bool HasChapter => ChapterWorkflow?.Chapter is not null || Context?.Chapter is not null;
|
||||
public bool IsChapterOnly => !HasScene && HasChapter;
|
||||
}
|
||||
|
||||
public sealed class SceneDetailsSaveViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
}
|
||||
|
||||
<main class="writing-context-page"
|
||||
data-writing-mode="context"
|
||||
data-writing-context
|
||||
data-context-url="@Url.Action("ContextScene", "Writing")"
|
||||
data-scope-project-id="@Model.ProjectID"
|
||||
@ -26,5 +27,6 @@
|
||||
</main>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-context.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
32
PlotLine/Views/Writing/Intent.cshtml
Normal file
32
PlotLine/Views/Writing/Intent.cshtml
Normal file
@ -0,0 +1,32 @@
|
||||
@model WritingIntentPageViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Writing Intent";
|
||||
ViewData["ProjectSection"] = "Writer";
|
||||
}
|
||||
|
||||
<main class="writing-context-page"
|
||||
data-writing-mode="intent"
|
||||
data-writing-intent
|
||||
data-context-url="@Url.Action("IntentScene", "Writing")"
|
||||
data-scope-project-id="@Model.ProjectID"
|
||||
data-scope-book-id="@Model.BookID">
|
||||
<header class="writing-context-shell-header">
|
||||
<div>
|
||||
<p class="eyebrow">Writing Intent</p>
|
||||
<h1>Writing Intent</h1>
|
||||
</div>
|
||||
<div class="writing-context-status" data-writing-intent-status>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section data-writing-intent-content>
|
||||
<partial name="_WritingIntentContent" model="Model.Display ?? new WritingIntentDisplayViewModel()" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
|
||||
<script src="~/js/writing-intent.js" asp-append-version="true"></script>
|
||||
}
|
||||
53
PlotLine/Views/Writing/_WritingIntentChecklist.cshtml
Normal file
53
PlotLine/Views/Writing/_WritingIntentChecklist.cshtml
Normal file
@ -0,0 +1,53 @@
|
||||
@model WritingIntentDisplayViewModel
|
||||
@{
|
||||
var scene = Model.Context?.Scene;
|
||||
var workflow = scene?.Workflow;
|
||||
var checklist = scene?.ChecklistItems.OrderBy(x => x.IsCompleted).ThenBy(x => x.SortOrder).ThenBy(x => x.ItemText).ToList() ?? [];
|
||||
var completed = checklist.Count(x => x.IsCompleted);
|
||||
}
|
||||
|
||||
<div class="writing-context-grid">
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Workflow</p>
|
||||
@if (workflow is null || scene is null)
|
||||
{
|
||||
<p>No current scene workflow.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<dl class="writing-context-facts">
|
||||
<dt>Status</dt><dd>@workflow.DraftStatus</dd>
|
||||
<dt>Priority</dt><dd>@(workflow.Priority?.ToString() ?? "None")</dd>
|
||||
<dt>Estimate</dt><dd>@(workflow.EstimatedWordCount?.ToString("N0") ?? "Not set")</dd>
|
||||
<dt>Actual</dt><dd>@(workflow.ActualWordCount?.ToString("N0") ?? "Not set")</dd>
|
||||
<dt>Blocked</dt><dd>@(workflow.IsBlocked ? (workflow.BlockedReason ?? "Blocked") : "No")</dd>
|
||||
</dl>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Checklist</p>
|
||||
@if (!checklist.Any())
|
||||
{
|
||||
<p>No checklist items.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>@completed / @checklist.Count completed</p>
|
||||
<ul class="writing-context-list writing-intent-checklist">
|
||||
@foreach (var item in checklist)
|
||||
{
|
||||
<li class="@(item.IsCompleted ? "is-complete" : "")">
|
||||
<span>@(item.IsCompleted ? "Done" : "Planned")</span>
|
||||
<span>@item.ItemText</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card writing-context-card--wide">
|
||||
<p class="eyebrow">Future Fulfilment</p>
|
||||
<p>Stored checklist and workflow states are shown as recorded. Automatic fulfilment detection is not enabled here.</p>
|
||||
</article>
|
||||
</div>
|
||||
111
PlotLine/Views/Writing/_WritingIntentContent.cshtml
Normal file
111
PlotLine/Views/Writing/_WritingIntentContent.cshtml
Normal file
@ -0,0 +1,111 @@
|
||||
@model WritingIntentDisplayViewModel
|
||||
@{
|
||||
var scene = Model.Context?.Scene;
|
||||
var position = Model.Context?.Position;
|
||||
var workflow = Model.ChapterWorkflow;
|
||||
var chapter = workflow?.Chapter ?? Model.Context?.Chapter;
|
||||
var book = workflow?.Book ?? Model.Context?.Book;
|
||||
var project = workflow?.Project ?? Model.Context?.Project;
|
||||
var projectId = position?.ProjectId ?? project?.ProjectID;
|
||||
var bookId = position?.BookId ?? book?.BookID;
|
||||
var chapterId = position?.ChapterId ?? chapter?.ChapterID;
|
||||
var sceneId = position?.SceneId;
|
||||
}
|
||||
|
||||
<div class="writing-context-content"
|
||||
data-writing-intent-fragment
|
||||
data-project-id="@projectId"
|
||||
data-book-id="@bookId"
|
||||
data-chapter-id="@chapterId"
|
||||
data-scene-id="@sceneId"
|
||||
data-follow-status="@Model.FollowStatus">
|
||||
@if (!Model.HasChapter && scene is null)
|
||||
{
|
||||
<section class="writing-context-empty">
|
||||
<p class="eyebrow">Waiting</p>
|
||||
<h2>Waiting for Word...</h2>
|
||||
<p>Open a linked manuscript or choose a planned chapter or scene once intent is available.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<header class="writing-context-scene-header">
|
||||
<div>
|
||||
<p class="eyebrow">@project?.ProjectName</p>
|
||||
<h2>@(scene?.SceneTitle ?? $"Chapter {chapter?.ChapterNumber:g}: {chapter?.ChapterTitle}")</h2>
|
||||
<p>
|
||||
@book?.BookDisplayTitle
|
||||
@if (chapter is not null)
|
||||
{
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>Chapter @chapter.ChapterNumber:g</span>
|
||||
}
|
||||
@if (scene is not null)
|
||||
{
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>Scene @scene.SceneNumber:g</span>
|
||||
}
|
||||
</p>
|
||||
@if (Model.IsChapterOnly)
|
||||
{
|
||||
<p>No current scene. Showing chapter intent.</p>
|
||||
}
|
||||
else if (scene is not null && string.IsNullOrWhiteSpace(scene.Summary) && string.IsNullOrWhiteSpace(scene.ScenePurposeNotes) && !scene.ChecklistItems.Any())
|
||||
{
|
||||
<p>No scene-specific plan. Showing chapter intent.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="writing-context-scene-actions">
|
||||
@if (chapter is not null)
|
||||
{
|
||||
<label for="writing-intent-chapter-select">Jump to chapter</label>
|
||||
<select id="writing-intent-chapter-select" class="form-select form-select-sm" data-writing-intent-chapter-select>
|
||||
<option value="@chapter.ChapterID" selected>Chapter @chapter.ChapterNumber:g: @chapter.ChapterTitle</option>
|
||||
</select>
|
||||
}
|
||||
<label for="writing-intent-scene-select">Jump to scene</label>
|
||||
<select id="writing-intent-scene-select" class="form-select form-select-sm" data-writing-intent-scene-select>
|
||||
<option value="">No current scene</option>
|
||||
@foreach (var option in Model.SceneOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
@if (scene is not null)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Edit Scene</a>
|
||||
}
|
||||
else if (chapter is not null)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-controller="Writer" asp-action="Chapter" asp-route-id="@chapter.ChapterID">Edit Chapter Plan</a>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="writing-context-tabs" aria-label="Writing intent tabs">
|
||||
<button type="button" class="active" data-writing-intent-tab="plan">Plan</button>
|
||||
<button type="button" data-writing-intent-tab="must-include">Must Include</button>
|
||||
<button type="button" data-writing-intent-tab="notes">Notes</button>
|
||||
<button type="button" data-writing-intent-tab="threads">Threads</button>
|
||||
<button type="button" data-writing-intent-tab="checklist">Checklist</button>
|
||||
</nav>
|
||||
|
||||
<div class="writing-context-tab-panels">
|
||||
<section class="writing-context-tab-panel active" data-writing-intent-panel="plan">
|
||||
<partial name="_WritingIntentPlan" model="Model" />
|
||||
</section>
|
||||
<section class="writing-context-tab-panel" data-writing-intent-panel="must-include" hidden>
|
||||
<partial name="_WritingIntentMustInclude" model="Model" />
|
||||
</section>
|
||||
<section class="writing-context-tab-panel" data-writing-intent-panel="notes" hidden>
|
||||
<partial name="_WritingIntentNotes" model="Model" />
|
||||
</section>
|
||||
<section class="writing-context-tab-panel" data-writing-intent-panel="threads" hidden>
|
||||
<partial name="_WritingIntentThreads" model="Model" />
|
||||
</section>
|
||||
<section class="writing-context-tab-panel" data-writing-intent-panel="checklist" hidden>
|
||||
<partial name="_WritingIntentChecklist" model="Model" />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
42
PlotLine/Views/Writing/_WritingIntentMustInclude.cshtml
Normal file
42
PlotLine/Views/Writing/_WritingIntentMustInclude.cshtml
Normal file
@ -0,0 +1,42 @@
|
||||
@model WritingIntentDisplayViewModel
|
||||
@{
|
||||
var scene = Model.Context?.Scene;
|
||||
var goal = Model.ChapterWorkflow?.Goal;
|
||||
var plannedItems = scene?.ChecklistItems.OrderBy(x => x.IsCompleted).ThenBy(x => x.SortOrder).ThenBy(x => x.ItemText).ToList() ?? [];
|
||||
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
<div class="writing-context-grid">
|
||||
<article class="writing-context-card writing-context-card--wide">
|
||||
<p class="eyebrow">Chapter Must Include</p>
|
||||
<p>@TextOr(goal?.MustInclude, "No chapter-level must-include notes yet.")</p>
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Scene Checklist</p>
|
||||
@if (!plannedItems.Any())
|
||||
{
|
||||
<p>No scene checklist items.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="writing-context-list writing-intent-checklist">
|
||||
@foreach (var item in plannedItems)
|
||||
{
|
||||
<li class="@(item.IsCompleted ? "is-complete" : "")">
|
||||
<span>@(item.IsCompleted ? "Done" : "Planned")</span>
|
||||
<span>@item.ItemText</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Relevant Goals</p>
|
||||
<ul class="writing-context-list">
|
||||
<li><strong>Goal</strong> @TextOr(goal?.GoalSummary, "No chapter goal summary yet.")</li>
|
||||
<li><strong>Reader</strong> @TextOr(goal?.ReaderTakeaway, "No reader takeaway yet.")</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
36
PlotLine/Views/Writing/_WritingIntentNotes.cshtml
Normal file
36
PlotLine/Views/Writing/_WritingIntentNotes.cshtml
Normal file
@ -0,0 +1,36 @@
|
||||
@model WritingIntentDisplayViewModel
|
||||
@{
|
||||
var scene = Model.Context?.Scene;
|
||||
var goal = Model.ChapterWorkflow?.Goal;
|
||||
var notes = scene?.Notes.OrderByDescending(x => x.IsPinned).ThenBy(x => x.IsResolved).ThenByDescending(x => x.UpdatedDate).ToList() ?? [];
|
||||
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
<div class="writing-context-grid">
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Chapter Risks</p>
|
||||
<p>@TextOr(goal?.RisksOrConcerns, "No chapter risks or concerns recorded.")</p>
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Scene Notes</p>
|
||||
@if (!notes.Any())
|
||||
{
|
||||
<p>No writing notes for this scene.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="writing-context-list">
|
||||
@foreach (var note in notes)
|
||||
{
|
||||
<li>
|
||||
<strong>@(string.IsNullOrWhiteSpace(note.NoteTitle) ? note.TypeName : note.NoteTitle)</strong>
|
||||
@if (note.IsPinned) { <span class="writing-intent-status-label">Pinned</span> }
|
||||
@if (note.IsResolved) { <span class="writing-intent-status-label">Resolved</span> }
|
||||
<p>@note.NoteText</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
53
PlotLine/Views/Writing/_WritingIntentPlan.cshtml
Normal file
53
PlotLine/Views/Writing/_WritingIntentPlan.cshtml
Normal file
@ -0,0 +1,53 @@
|
||||
@model WritingIntentDisplayViewModel
|
||||
@{
|
||||
var scene = Model.Context?.Scene;
|
||||
var workflow = Model.ChapterWorkflow;
|
||||
var chapter = workflow?.Chapter ?? Model.Context?.Chapter;
|
||||
var goal = workflow?.Goal;
|
||||
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
<div class="writing-context-grid writing-context-grid--overview">
|
||||
<article class="writing-context-card writing-context-card--hero">
|
||||
<p class="eyebrow">Chapter Plan</p>
|
||||
<h3>Chapter @chapter?.ChapterNumber:g: @chapter?.ChapterTitle</h3>
|
||||
<p>@TextOr(chapter?.Summary, "No chapter summary has been written yet.")</p>
|
||||
<dl class="writing-context-facts">
|
||||
<dt>Purpose</dt><dd>@TextOr(chapter?.ChapterPurposeName, "No chapter purpose selected.")</dd>
|
||||
<dt>Goal</dt><dd>@TextOr(goal?.GoalSummary, "No chapter goal summary yet.")</dd>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Current Scene</p>
|
||||
@if (scene is null)
|
||||
{
|
||||
<p>No current scene. Chapter intent is shown until Word reports a scene.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h3>Scene @scene.SceneNumber:g: @scene.SceneTitle</h3>
|
||||
<p>@TextOr(scene.Summary, "No scene summary has been written yet.")</p>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Scene Purpose</p>
|
||||
<p>@TextOr(scene?.ScenePurposeNotes, "No scene-specific purpose notes yet.")</p>
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Expected Outcome</p>
|
||||
<p>@TextOr(scene?.SceneOutcomeNotes, "No expected outcome has been captured for this scene.")</p>
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Emotional Goal</p>
|
||||
<p>@TextOr(goal?.EmotionalGoal, "No emotional goal recorded for this chapter.")</p>
|
||||
</article>
|
||||
|
||||
<article class="writing-context-card">
|
||||
<p class="eyebrow">Reader Takeaway</p>
|
||||
<p>@TextOr(goal?.ReaderTakeaway, "No reader takeaway recorded for this chapter.")</p>
|
||||
</article>
|
||||
</div>
|
||||
29
PlotLine/Views/Writing/_WritingIntentThreads.cshtml
Normal file
29
PlotLine/Views/Writing/_WritingIntentThreads.cshtml
Normal file
@ -0,0 +1,29 @@
|
||||
@model WritingIntentDisplayViewModel
|
||||
@{
|
||||
var scene = Model.Context?.Scene;
|
||||
var threadEvents = scene?.ThreadEvents.ToList() ?? [];
|
||||
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
<div class="writing-context-grid">
|
||||
<article class="writing-context-card writing-context-card--wide">
|
||||
<p class="eyebrow">Story Threads</p>
|
||||
@if (!threadEvents.Any())
|
||||
{
|
||||
<p>No story thread events recorded for this scene.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="writing-context-list">
|
||||
@foreach (var item in threadEvents)
|
||||
{
|
||||
<li>
|
||||
<strong>@item.PlotLineName / @item.ThreadTitle</strong>
|
||||
<span class="writing-intent-status-label">@TextOr(item.EventTypeName, "Thread event")</span>
|
||||
<p>@TextOr(item.EventTitle, item.EventDescription ?? "Thread intention recorded here.")</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
@ -189,6 +189,38 @@ html {
|
||||
color: var(--plotline-muted);
|
||||
}
|
||||
|
||||
.writing-intent-status-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: .35rem;
|
||||
padding: .1rem .45rem;
|
||||
border: 1px solid var(--plotline-line);
|
||||
border-radius: 999px;
|
||||
background: var(--plotline-paper);
|
||||
color: var(--plotline-muted);
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.writing-intent-checklist li {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
gap: .5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.writing-intent-checklist li > span:first-child {
|
||||
color: var(--plotline-muted);
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.writing-intent-checklist li.is-complete > span:last-child {
|
||||
color: var(--plotline-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.writing-context-people-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 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
@ -164,6 +164,14 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.querySelector("[data-writing-mode]")
|
||||
|| path === "/writing/context"
|
||||
|| path.startsWith("/writing/context/")
|
||||
|| path === "/writing/intent"
|
||||
|| path.startsWith("/writing/intent/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !unsafePathPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
};
|
||||
|
||||
|
||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,165 +1,17 @@
|
||||
(() => {
|
||||
const root = document.querySelector("[data-writing-context]");
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHost = root.querySelector("[data-writing-context-content]");
|
||||
const statusText = root.querySelector("[data-writing-context-status-text]");
|
||||
const endpoint = root.dataset.contextUrl || "/Writing/Context/Scene";
|
||||
const tabStorageKey = "plotdirector.writingContext.activeTab";
|
||||
const scopedProjectId = Number.parseInt(root.dataset.scopeProjectId || "", 10);
|
||||
const scopedBookId = Number.parseInt(root.dataset.scopeBookId || "", 10);
|
||||
let currentSceneId = Number.parseInt(contentHost?.querySelector("[data-writing-context-fragment]")?.dataset.sceneId || "", 10);
|
||||
let loadingSceneId = null;
|
||||
|
||||
const setStatus = (value) => {
|
||||
if (statusText && value) {
|
||||
statusText.textContent = value;
|
||||
}
|
||||
};
|
||||
|
||||
const activeTab = () => {
|
||||
const active = contentHost?.querySelector("[data-writing-context-tab].active")?.dataset.writingContextTab;
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
try {
|
||||
return localStorage.getItem(tabStorageKey) || "overview";
|
||||
} catch {
|
||||
return "overview";
|
||||
}
|
||||
};
|
||||
|
||||
const activateTab = (tabName) => {
|
||||
const nextTab = tabName || "overview";
|
||||
contentHost?.querySelectorAll("[data-writing-context-tab]").forEach((button) => {
|
||||
const active = button.dataset.writingContextTab === nextTab;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
window.PlotDirectorWritingFollow?.initialize({
|
||||
rootSelector: "[data-writing-context]",
|
||||
contentSelector: "[data-writing-context-content]",
|
||||
fragmentSelector: "[data-writing-context-fragment]",
|
||||
statusTextSelector: "[data-writing-context-status-text]",
|
||||
tabSelector: "[data-writing-context-tab]",
|
||||
panelSelector: "[data-writing-context-panel]",
|
||||
selectSelector: "[data-writing-context-scene-select]",
|
||||
tabDataKey: "writingContextTab",
|
||||
panelDataKey: "writingContextPanel",
|
||||
storageKey: "plotdirector.writingContext.activeTab",
|
||||
defaultTab: "overview",
|
||||
defaultEndpoint: "/Writing/Context/Scene",
|
||||
source: "WritingContext"
|
||||
});
|
||||
contentHost?.querySelectorAll("[data-writing-context-panel]").forEach((panel) => {
|
||||
const active = panel.dataset.writingContextPanel === nextTab;
|
||||
panel.classList.toggle("active", active);
|
||||
panel.hidden = !active;
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(tabStorageKey, nextTab);
|
||||
} catch {
|
||||
// Remembering the tab is a convenience only.
|
||||
}
|
||||
};
|
||||
|
||||
const wireFragment = () => {
|
||||
activateTab(activeTab());
|
||||
const fragment = contentHost?.querySelector("[data-writing-context-fragment]");
|
||||
const fragmentStatus = fragment?.dataset.followStatus;
|
||||
if (fragmentStatus) {
|
||||
setStatus(fragmentStatus);
|
||||
}
|
||||
|
||||
const fragmentSceneId = Number.parseInt(fragment?.dataset.sceneId || "", 10);
|
||||
if (Number.isInteger(fragmentSceneId) && fragmentSceneId > 0) {
|
||||
currentSceneId = fragmentSceneId;
|
||||
}
|
||||
|
||||
contentHost?.querySelectorAll("[data-writing-context-tab]").forEach((button) => {
|
||||
button.addEventListener("click", () => activateTab(button.dataset.writingContextTab));
|
||||
});
|
||||
|
||||
contentHost?.querySelector("[data-writing-context-scene-select]")?.addEventListener("change", (event) => {
|
||||
const sceneId = Number.parseInt(event.target.value || "", 10);
|
||||
if (!Number.isInteger(sceneId) || sceneId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = contentHost?.querySelector("[data-writing-context-fragment]");
|
||||
window.PlotDirectorStoryPosition?.publishChanged({
|
||||
source: "WritingContext",
|
||||
projectId: fragment?.dataset.projectId,
|
||||
bookId: fragment?.dataset.bookId,
|
||||
chapterId: fragment?.dataset.chapterId,
|
||||
sceneId
|
||||
}, "WritingContext");
|
||||
});
|
||||
};
|
||||
|
||||
const inScope = (position) => {
|
||||
if (Number.isInteger(scopedProjectId) && scopedProjectId > 0 && position.projectId !== scopedProjectId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(scopedBookId) && scopedBookId > 0 && position.bookId && position.bookId !== scopedBookId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadPosition = async (position) => {
|
||||
if (!position || !Number.isInteger(position.sceneId) || position.sceneId <= 0 || !inScope(position)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (position.sceneId === currentSceneId || position.sceneId === loadingSceneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = activeTab();
|
||||
loadingSceneId = position.sceneId;
|
||||
setStatus("Updating");
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
if (position.projectId) url.searchParams.set("projectId", position.projectId);
|
||||
if (position.bookId) url.searchParams.set("bookId", position.bookId);
|
||||
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
|
||||
url.searchParams.set("sceneId", position.sceneId);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Context request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
contentHost.innerHTML = await response.text();
|
||||
currentSceneId = position.sceneId;
|
||||
wireFragment();
|
||||
activateTab(tab);
|
||||
} catch (error) {
|
||||
console.debug("Writing Context could not update.", error);
|
||||
setStatus("Connected, waiting for scene");
|
||||
} finally {
|
||||
loadingSceneId = null;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
|
||||
void loadPosition(event.detail);
|
||||
});
|
||||
|
||||
if (window.signalR) {
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/word-companion-follow")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on("storyPositionChanged", (payload) => {
|
||||
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
|
||||
});
|
||||
|
||||
connection.start()
|
||||
.then(() => {
|
||||
if (!currentSceneId) {
|
||||
setStatus("Connected, waiting for scene");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug("Writing Context follow connection unavailable.", error);
|
||||
setStatus("Word not connected");
|
||||
});
|
||||
} else {
|
||||
setStatus("Word not connected");
|
||||
}
|
||||
|
||||
wireFragment();
|
||||
})();
|
||||
|
||||
231
PlotLine/wwwroot/js/writing-follow.js
Normal file
231
PlotLine/wwwroot/js/writing-follow.js
Normal file
@ -0,0 +1,231 @@
|
||||
(() => {
|
||||
const defaults = {
|
||||
rootSelector: "",
|
||||
contentSelector: "",
|
||||
fragmentSelector: "",
|
||||
statusTextSelector: "",
|
||||
tabSelector: "",
|
||||
panelSelector: "",
|
||||
selectSelector: "",
|
||||
tabDataKey: "",
|
||||
panelDataKey: "",
|
||||
storageKey: "",
|
||||
defaultTab: "",
|
||||
defaultEndpoint: "",
|
||||
source: "WritingMode",
|
||||
allowChapterOnly: false,
|
||||
waitingStatus: "Connected, waiting for scene",
|
||||
updatingStatus: "Updating",
|
||||
disconnectedStatus: "Word not connected"
|
||||
};
|
||||
|
||||
const parsePositiveInt = (value) => {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const initialize = (options) => {
|
||||
const config = { ...defaults, ...options };
|
||||
const root = document.querySelector(config.rootSelector);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentHost = root.querySelector(config.contentSelector);
|
||||
const statusText = root.querySelector(config.statusTextSelector);
|
||||
const endpoint = root.dataset.contextUrl || config.defaultEndpoint;
|
||||
const scopedProjectId = parsePositiveInt(root.dataset.scopeProjectId);
|
||||
const scopedBookId = parsePositiveInt(root.dataset.scopeBookId);
|
||||
let currentFragment = contentHost?.querySelector(config.fragmentSelector);
|
||||
let currentSceneId = parsePositiveInt(currentFragment?.dataset.sceneId);
|
||||
let currentChapterId = parsePositiveInt(currentFragment?.dataset.chapterId);
|
||||
let loadingSceneId = null;
|
||||
let loadingChapterId = null;
|
||||
|
||||
const setStatus = (value) => {
|
||||
if (statusText && value) {
|
||||
statusText.textContent = value;
|
||||
}
|
||||
};
|
||||
|
||||
const readStoredTab = () => {
|
||||
try {
|
||||
return localStorage.getItem(config.storageKey) || config.defaultTab;
|
||||
} catch {
|
||||
return config.defaultTab;
|
||||
}
|
||||
};
|
||||
|
||||
const activeTab = () => {
|
||||
const active = contentHost?.querySelector(`${config.tabSelector}.active`)?.dataset[config.tabDataKey];
|
||||
return active || readStoredTab();
|
||||
};
|
||||
|
||||
const activateTab = (tabName) => {
|
||||
const nextTab = tabName || config.defaultTab;
|
||||
contentHost?.querySelectorAll(config.tabSelector).forEach((button) => {
|
||||
const active = button.dataset[config.tabDataKey] === nextTab;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
});
|
||||
contentHost?.querySelectorAll(config.panelSelector).forEach((panel) => {
|
||||
const active = panel.dataset[config.panelDataKey] === nextTab;
|
||||
panel.classList.toggle("active", active);
|
||||
panel.hidden = !active;
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(config.storageKey, nextTab);
|
||||
} catch {
|
||||
// Remembering the selected tab is a convenience only.
|
||||
}
|
||||
};
|
||||
|
||||
const wireFragment = () => {
|
||||
activateTab(activeTab());
|
||||
const fragment = contentHost?.querySelector(config.fragmentSelector);
|
||||
const fragmentStatus = fragment?.dataset.followStatus;
|
||||
if (fragmentStatus) {
|
||||
setStatus(fragmentStatus);
|
||||
}
|
||||
|
||||
const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId);
|
||||
if (fragmentSceneId) {
|
||||
currentSceneId = fragmentSceneId;
|
||||
}
|
||||
|
||||
const fragmentChapterId = parsePositiveInt(fragment?.dataset.chapterId);
|
||||
if (fragmentChapterId) {
|
||||
currentChapterId = fragmentChapterId;
|
||||
}
|
||||
|
||||
contentHost?.querySelectorAll(config.tabSelector).forEach((button) => {
|
||||
button.addEventListener("click", () => activateTab(button.dataset[config.tabDataKey]));
|
||||
});
|
||||
|
||||
contentHost?.querySelector(config.selectSelector)?.addEventListener("change", (event) => {
|
||||
const sceneId = parsePositiveInt(event.target.value);
|
||||
if (!sceneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = contentHost?.querySelector(config.fragmentSelector);
|
||||
window.PlotDirectorStoryPosition?.publishChanged({
|
||||
source: config.source,
|
||||
projectId: fragment?.dataset.projectId,
|
||||
bookId: fragment?.dataset.bookId,
|
||||
chapterId: fragment?.dataset.chapterId,
|
||||
sceneId
|
||||
}, config.source);
|
||||
});
|
||||
|
||||
contentHost?.querySelector("[data-writing-intent-chapter-select]")?.addEventListener("change", (event) => {
|
||||
const chapterId = parsePositiveInt(event.target.value);
|
||||
if (!chapterId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = contentHost?.querySelector(config.fragmentSelector);
|
||||
window.PlotDirectorStoryPosition?.publishChanged({
|
||||
source: config.source,
|
||||
projectId: fragment?.dataset.projectId,
|
||||
bookId: fragment?.dataset.bookId,
|
||||
chapterId
|
||||
}, config.source);
|
||||
});
|
||||
};
|
||||
|
||||
const inScope = (position) => {
|
||||
if (scopedProjectId && position.projectId !== scopedProjectId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scopedBookId && position.bookId && position.bookId !== scopedBookId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadPosition = async (position) => {
|
||||
const hasScene = Number.isInteger(position?.sceneId) && position.sceneId > 0;
|
||||
const hasChapter = Number.isInteger(position?.chapterId) && position.chapterId > 0;
|
||||
if (!position || !inScope(position) || (!hasScene && (!config.allowChapterOnly || !hasChapter))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasScene && (position.sceneId === currentSceneId || position.sceneId === loadingSceneId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasScene && (position.chapterId === currentChapterId || position.chapterId === loadingChapterId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = activeTab();
|
||||
loadingSceneId = hasScene ? position.sceneId : null;
|
||||
loadingChapterId = !hasScene ? position.chapterId : null;
|
||||
setStatus(config.updatingStatus);
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
if (position.projectId) url.searchParams.set("projectId", position.projectId);
|
||||
if (position.bookId) url.searchParams.set("bookId", position.bookId);
|
||||
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
|
||||
if (hasScene) url.searchParams.set("sceneId", position.sceneId);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`${config.source} request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
contentHost.innerHTML = await response.text();
|
||||
if (hasScene) {
|
||||
currentSceneId = position.sceneId;
|
||||
}
|
||||
if (hasChapter) {
|
||||
currentChapterId = position.chapterId;
|
||||
}
|
||||
wireFragment();
|
||||
activateTab(tab);
|
||||
} catch (error) {
|
||||
console.debug(`${config.source} could not update.`, error);
|
||||
setStatus(config.waitingStatus);
|
||||
} finally {
|
||||
loadingSceneId = null;
|
||||
loadingChapterId = null;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
|
||||
void loadPosition(event.detail);
|
||||
});
|
||||
|
||||
if (window.signalR) {
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/word-companion-follow")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on("storyPositionChanged", (payload) => {
|
||||
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
|
||||
});
|
||||
|
||||
connection.start()
|
||||
.then(() => {
|
||||
if (!currentSceneId) {
|
||||
setStatus(config.waitingStatus);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`${config.source} follow connection unavailable.`, error);
|
||||
setStatus(config.disconnectedStatus);
|
||||
});
|
||||
} else {
|
||||
setStatus(config.disconnectedStatus);
|
||||
}
|
||||
|
||||
wireFragment();
|
||||
return { loadPosition, activateTab };
|
||||
};
|
||||
|
||||
window.PlotDirectorWritingFollow = { initialize };
|
||||
})();
|
||||
18
PlotLine/wwwroot/js/writing-intent.js
Normal file
18
PlotLine/wwwroot/js/writing-intent.js
Normal file
@ -0,0 +1,18 @@
|
||||
(() => {
|
||||
window.PlotDirectorWritingFollow?.initialize({
|
||||
rootSelector: "[data-writing-intent]",
|
||||
contentSelector: "[data-writing-intent-content]",
|
||||
fragmentSelector: "[data-writing-intent-fragment]",
|
||||
statusTextSelector: "[data-writing-intent-status-text]",
|
||||
tabSelector: "[data-writing-intent-tab]",
|
||||
panelSelector: "[data-writing-intent-panel]",
|
||||
selectSelector: "[data-writing-intent-scene-select]",
|
||||
tabDataKey: "writingIntentTab",
|
||||
panelDataKey: "writingIntentPanel",
|
||||
storageKey: "plotdirector.writingIntent.activeTab",
|
||||
defaultTab: "plan",
|
||||
defaultEndpoint: "/Writing/Intent/Scene",
|
||||
source: "WritingIntent",
|
||||
allowChapterOnly: true
|
||||
});
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user