395 lines
16 KiB
ObjectPascal
395 lines
16 KiB
ObjectPascal
unit Myc.Api.MarkdownStream;
|
|
|
|
interface
|
|
|
|
uses
|
|
System.SysUtils,
|
|
System.Classes,
|
|
System.IOUtils,
|
|
System.Threading,
|
|
System.Generics.Collections,
|
|
System.JSON,
|
|
FMX.WebBrowser;
|
|
|
|
type
|
|
TBlockType = (btMarkdown, btRawText, btHtml);
|
|
|
|
// Manages an incremental Markdown/HTML stream within a TWebBrowser.
|
|
// Supports block-based rendering and collapsible raw text sections.
|
|
IMarkdownStream = interface
|
|
procedure InitializeBrowser(const Browser: TWebBrowser);
|
|
procedure BeginBlock(const AType: TBlockType; const ATitle: string = '');
|
|
procedure Append(const Content: string);
|
|
procedure Clear;
|
|
end;
|
|
|
|
TMarkdownStream = class(TInterfacedObject, IMarkdownStream)
|
|
strict private
|
|
type
|
|
TCmd = record
|
|
Func: string;
|
|
Arg: string;
|
|
constructor Create(const F, A: string);
|
|
end;
|
|
strict private
|
|
FBrowser: TWebBrowser;
|
|
FIsReady: Boolean;
|
|
FCommandQueue: TList<TCmd>;
|
|
FTempFileName: string;
|
|
|
|
procedure OnDidFinishLoad(Sender: TObject);
|
|
function GetBaseHtml: string;
|
|
procedure ExecuteJS(const FunctionName, Data: string);
|
|
procedure FlushQueue;
|
|
function BlockTypeToString(const AType: TBlockType): string;
|
|
public
|
|
constructor Create;
|
|
destructor Destroy; override;
|
|
procedure InitializeBrowser(const Browser: TWebBrowser);
|
|
|
|
procedure BeginBlock(const AType: TBlockType; const ATitle: string = '');
|
|
procedure Append(const Content: string);
|
|
procedure Clear;
|
|
end;
|
|
|
|
implementation
|
|
|
|
{ TMarkdownStream.TCmd }
|
|
|
|
constructor TMarkdownStream.TCmd.Create(const F, A: string);
|
|
begin
|
|
Func := F;
|
|
Arg := A;
|
|
end;
|
|
|
|
{ TMarkdownStream }
|
|
|
|
constructor TMarkdownStream.Create;
|
|
begin
|
|
inherited;
|
|
FCommandQueue := TList<TCmd>.Create;
|
|
FIsReady := False;
|
|
FTempFileName := TPath.Combine(TPath.GetTempPath, 'myc_md_stream.html');
|
|
end;
|
|
|
|
destructor TMarkdownStream.Destroy;
|
|
begin
|
|
if Assigned(FBrowser) then
|
|
FBrowser.OnDidFinishLoad := nil;
|
|
FCommandQueue.Free;
|
|
if FileExists(FTempFileName) then
|
|
try
|
|
TFile.Delete(FTempFileName);
|
|
except
|
|
end;
|
|
inherited;
|
|
end;
|
|
|
|
procedure TMarkdownStream.InitializeBrowser(const Browser: TWebBrowser);
|
|
begin
|
|
FBrowser := Browser;
|
|
FIsReady := False;
|
|
FCommandQueue.Clear;
|
|
|
|
if Assigned(FBrowser) then
|
|
begin
|
|
FBrowser.OnDidFinishLoad := OnDidFinishLoad;
|
|
try
|
|
TFile.WriteAllText(FTempFileName, GetBaseHtml, TEncoding.UTF8);
|
|
FBrowser.Navigate('file://' + FTempFileName);
|
|
except
|
|
on E: Exception do
|
|
;
|
|
end;
|
|
end;
|
|
end;
|
|
|
|
procedure TMarkdownStream.OnDidFinishLoad(Sender: TObject);
|
|
begin
|
|
TThread.ForceQueue(
|
|
nil,
|
|
procedure
|
|
begin
|
|
FIsReady := True;
|
|
FlushQueue;
|
|
end
|
|
);
|
|
end;
|
|
|
|
procedure TMarkdownStream.FlushQueue;
|
|
var
|
|
cmd: TCmd;
|
|
begin
|
|
if not Assigned(FBrowser) then
|
|
exit;
|
|
for cmd in FCommandQueue do
|
|
ExecuteJS(cmd.Func, cmd.Arg);
|
|
FCommandQueue.Clear;
|
|
end;
|
|
|
|
function TMarkdownStream.BlockTypeToString(const AType: TBlockType): string;
|
|
begin
|
|
case AType of
|
|
btMarkdown: Result := 'markdown';
|
|
btRawText: Result := 'text';
|
|
btHtml: Result := 'html';
|
|
end;
|
|
end;
|
|
|
|
procedure TMarkdownStream.BeginBlock(const AType: TBlockType; const ATitle: string);
|
|
var
|
|
payload: string;
|
|
begin
|
|
payload := BlockTypeToString(AType) + '|' + ATitle;
|
|
if FIsReady then
|
|
ExecuteJS('startBlock', payload)
|
|
else
|
|
FCommandQueue.Add(TCmd.Create('startBlock', payload));
|
|
end;
|
|
|
|
procedure TMarkdownStream.Append(const Content: string);
|
|
begin
|
|
if Content.IsEmpty then
|
|
exit;
|
|
if FIsReady then
|
|
ExecuteJS('appendData', Content)
|
|
else
|
|
FCommandQueue.Add(TCmd.Create('appendData', Content));
|
|
end;
|
|
|
|
procedure TMarkdownStream.Clear;
|
|
begin
|
|
FCommandQueue.Clear;
|
|
if FIsReady then
|
|
ExecuteJS('clearAll', '')
|
|
else
|
|
FCommandQueue.Add(TCmd.Create('clearAll', ''));
|
|
end;
|
|
|
|
procedure TMarkdownStream.ExecuteJS(const FunctionName, Data: string);
|
|
var
|
|
jsCommand: string;
|
|
jsonVal: TJSONValue;
|
|
begin
|
|
if not Assigned(FBrowser) then
|
|
exit;
|
|
|
|
// Sicherer Umgang mit leeren Daten und korrektes Escaping
|
|
jsonVal := TJSONString.Create(Data);
|
|
try
|
|
jsCommand := Format('window.%s(%s);', [FunctionName, jsonVal.ToJSON]);
|
|
try
|
|
FBrowser.EvaluateJavaScript(jsCommand);
|
|
except
|
|
// Browser-Context evtl. während der Zerstörung nicht mehr valide
|
|
end;
|
|
finally
|
|
jsonVal.Free;
|
|
end;
|
|
end;
|
|
|
|
function TMarkdownStream.GetBaseHtml: string;
|
|
begin
|
|
Result :=
|
|
'''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/delphi.min.js"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
|
|
<style>
|
|
body {
|
|
font-family: "Segoe UI Variable Text", "Segoe UI", sans-serif;
|
|
font-size: 14px; line-height: 1.5; padding: 16px; margin: 0; background: #fff;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
#history { display: flex; flex-direction: column; gap: 12px; }
|
|
#active { margin-top: 12px; }
|
|
|
|
.block-header {
|
|
font-family: "Segoe UI Variable Display", sans-serif;
|
|
font-weight: 600; font-size: 11px; text-transform: uppercase;
|
|
color: #0078d4; border-bottom: 1px solid #f0f0f0; margin-bottom: 6px;
|
|
}
|
|
|
|
/* Raw Text Blocks */
|
|
.raw-wrapper {
|
|
background-color: #f3f2f1;
|
|
border: 1px solid #edebe9;
|
|
border-radius: 4px;
|
|
margin: 4px 0;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
.raw-header {
|
|
padding: 6px 10px; font-size: 11px; font-weight: bold; color: #323130;
|
|
background: #faf9f8; display: flex; align-items: center;
|
|
border-bottom: 1px solid transparent; cursor: pointer; user-select: none;
|
|
}
|
|
.raw-header:hover { background: #f0f0f0; color: #0078d4; }
|
|
.icon-svg {
|
|
width: 14px; height: 14px; margin-right: 8px;
|
|
fill: none; stroke: currentColor; stroke-width: 2;
|
|
stroke-linecap: round; stroke-linejoin: round;
|
|
transition: transform 0.2s;
|
|
}
|
|
.raw-content {
|
|
font-family: "Cascadia Code", "Consolas", monospace;
|
|
font-size: 12.5px; white-space: pre-wrap;
|
|
padding: 10px; margin: 0; overflow: hidden; color: #201f1e;
|
|
}
|
|
.collapsed .raw-content { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
|
|
.collapsed .icon-svg { transform: rotate(-90deg); }
|
|
.expanded .icon-svg { transform: rotate(0deg); }
|
|
.expanded .raw-header { border-bottom-color: #edebe9; }
|
|
|
|
/* Markdown Specifics */
|
|
blockquote { border-left: 4px solid #0078d4; padding-left: 12px; color: #605e5c; }
|
|
|
|
/* Code Blocks & Copy Button */
|
|
.code-wrapper { position: relative; margin: 1em 0; }
|
|
pre { background: #f6f8fa; padding: 12px; border: 1px solid #d0d7de; border-radius: 6px; overflow-x: auto; margin: 0; }
|
|
code { font-family: "Cascadia Code", Consolas, monospace; font-size: 90%; }
|
|
|
|
.copy-btn {
|
|
position: sticky; top: 10px; float: right; z-index: 10;
|
|
margin-top: 10px; margin-bottom: -42px; margin-right: 10px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
width: 32px; height: 32px;
|
|
background-color: rgba(255, 255, 255, 0.8); backdrop-filter: blur(2px);
|
|
border: 1px solid #d0d7de; border-radius: 6px;
|
|
cursor: pointer; color: #57606a; transition: all 0.2s;
|
|
}
|
|
.copy-btn:hover { background-color: #ffffff; color: #0969da; border-color: #0969da; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
.copy-btn:active { background-color: #edeff2; transform: translateY(1px); }
|
|
.copy-btn svg { fill: currentColor; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="history"></div>
|
|
<div id="active"></div>
|
|
|
|
<script>
|
|
const historyDiv = document.getElementById("history");
|
|
const activeDiv = document.getElementById("active");
|
|
let currentMode = "markdown";
|
|
let currentTitle = "";
|
|
let currentBuffer = "";
|
|
|
|
// Icons
|
|
const chevronSvg = '<svg class="icon-svg" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"></polyline></svg>';
|
|
const iconCopy = '<svg viewBox="0 0 16 16" width="16" height="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>';
|
|
const iconCheck = '<svg viewBox="0 0 16 16" width="16" height="16" style="color: #2da44e"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>';
|
|
|
|
function toggleBlock(header) {
|
|
const wrapper = header.closest('.raw-wrapper');
|
|
if (wrapper.classList.contains('collapsed')) {
|
|
wrapper.classList.replace('collapsed', 'expanded');
|
|
} else {
|
|
wrapper.classList.replace('expanded', 'collapsed');
|
|
}
|
|
}
|
|
|
|
function addCopyButtons(container) {
|
|
const pres = container.querySelectorAll("pre");
|
|
pres.forEach(function(preBlock) {
|
|
if (preBlock.parentNode.classList.contains("code-wrapper")) return;
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "code-wrapper";
|
|
preBlock.parentNode.insertBefore(wrapper, preBlock);
|
|
wrapper.appendChild(preBlock);
|
|
|
|
const button = document.createElement("button");
|
|
button.className = "copy-btn";
|
|
button.innerHTML = iconCopy;
|
|
button.title = "Copy to Clipboard";
|
|
|
|
button.addEventListener("click", function() {
|
|
const code = preBlock.querySelector("code");
|
|
if (!code) return;
|
|
const text = code.innerText;
|
|
navigator.clipboard.writeText(text).then(function() {
|
|
button.innerHTML = iconCheck;
|
|
setTimeout(function() { button.innerHTML = iconCopy; }, 2000);
|
|
}, function(err) { console.error("Clipboard failed", err); });
|
|
});
|
|
|
|
wrapper.insertBefore(button, preBlock);
|
|
});
|
|
}
|
|
|
|
function renderTo(target, content, mode, title, isFinal) {
|
|
target.innerHTML = "";
|
|
if (mode === "markdown") {
|
|
if (title) {
|
|
const h = document.createElement("div");
|
|
h.className = "block-header";
|
|
h.innerText = title;
|
|
target.appendChild(h);
|
|
}
|
|
const c = document.createElement("div");
|
|
c.innerHTML = (typeof marked !== "undefined") ? marked.parse(content) : content;
|
|
target.appendChild(c);
|
|
|
|
// Highlight and add Copy Buttons
|
|
if (typeof hljs !== "undefined") {
|
|
c.querySelectorAll("pre code").forEach((block) => { hljs.highlightElement(block); });
|
|
}
|
|
addCopyButtons(c);
|
|
|
|
} else if (mode === "text") {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "raw-wrapper " + (isFinal ? "collapsed" : "expanded");
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "raw-header";
|
|
header.onclick = function() { toggleBlock(this); };
|
|
header.innerHTML = chevronSvg + '<span>' + (title || "Raw Output") + '</span>';
|
|
|
|
const code = document.createElement("pre");
|
|
code.className = "raw-content";
|
|
code.innerText = content;
|
|
|
|
wrapper.appendChild(header);
|
|
wrapper.appendChild(code);
|
|
target.appendChild(wrapper);
|
|
} else {
|
|
target.innerHTML = content;
|
|
}
|
|
}
|
|
|
|
window.startBlock = function(payload) {
|
|
const parts = payload.split('|');
|
|
if (currentBuffer.length > 0) {
|
|
const oldBlock = document.createElement("div");
|
|
renderTo(oldBlock, currentBuffer, currentMode, currentTitle, true);
|
|
historyDiv.appendChild(oldBlock);
|
|
}
|
|
currentMode = parts[0];
|
|
currentTitle = parts[1] || "";
|
|
currentBuffer = "";
|
|
activeDiv.innerHTML = "";
|
|
};
|
|
|
|
window.appendData = function(chunk) {
|
|
currentBuffer += chunk;
|
|
renderTo(activeDiv, currentBuffer, currentMode, currentTitle, false);
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
};
|
|
|
|
window.clearAll = function() {
|
|
currentBuffer = ""; activeDiv.innerHTML = ""; historyDiv.innerHTML = "";
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|
|
''';
|
|
end;
|
|
|
|
end.
|