add chats

This commit is contained in:
liquidex 2024-03-24 18:08:47 +01:00
parent 8f43531b47
commit 94328e0b93
12 changed files with 372 additions and 14 deletions

View file

@ -0,0 +1,11 @@
%% title = "testing ground for (chit)chats"
scripts = ["components/chat.js"]
styles = ["components/chat.css"]
% id = "01HSR695VNDXRGPC3XCHS3A61V"
- this is a test page for the treehouse's chat system.
% cast = "chat js"
id = "01HSR695VNDYS0GDB527B9NPJS"
template = true
- {% include_static chat/treehouse/dev/chats/test.json %}

View file

@ -291,6 +291,7 @@ impl Generator {
treehouse, treehouse,
config, config,
&mut config_derived_data, &mut config_derived_data,
paths,
parsed_tree.file_id, parsed_tree.file_id,
&roots.branches, &roots.branches,
); );

View file

@ -1,9 +1,10 @@
use std::fmt::Write; use std::{borrow::Cow, fmt::Write};
use pulldown_cmark::{BrokenLink, LinkType}; use pulldown_cmark::{BrokenLink, LinkType};
use treehouse_format::pull::BranchKind; use treehouse_format::pull::BranchKind;
use crate::{ use crate::{
cli::Paths,
config::{Config, ConfigDerivedData}, config::{Config, ConfigDerivedData},
html::EscapeAttribute, html::EscapeAttribute,
state::{FileId, Treehouse}, state::{FileId, Treehouse},
@ -20,6 +21,7 @@ pub fn branch_to_html(
treehouse: &mut Treehouse, treehouse: &mut Treehouse,
config: &Config, config: &Config,
config_derived_data: &mut ConfigDerivedData, config_derived_data: &mut ConfigDerivedData,
paths: &Paths<'_>,
file_id: FileId, file_id: FileId,
branch_id: SemaBranchId, branch_id: SemaBranchId,
) { ) {
@ -49,6 +51,11 @@ pub fn branch_to_html(
} else { } else {
"b" "b"
}; };
let component = if !branch.attributes.cast.is_empty() {
Cow::Owned(format!("{component} {}", branch.attributes.cast))
} else {
Cow::Borrowed(component)
};
let linked_branch = if let Content::Link(link) = &branch.attributes.content { let linked_branch = if let Content::Link(link) = &branch.attributes.content {
format!(" data-th-link=\"{}\"", EscapeHtml(link)) format!(" data-th-link=\"{}\"", EscapeHtml(link))
@ -62,9 +69,19 @@ pub fn branch_to_html(
"" ""
}; };
let mut data_attributes = String::new();
for (key, value) in &branch.attributes.data {
write!(
data_attributes,
" data-{key}=\"{}\"",
EscapeAttribute(value)
)
.unwrap();
}
write!( write!(
s, s,
"<li data-cast=\"{component}\" class=\"{class}\" id=\"{}\"{linked_branch}{do_not_persist}>", "<li data-cast=\"{component}\" class=\"{class}\" id=\"{}\"{linked_branch}{do_not_persist}{data_attributes}>",
EscapeAttribute(&branch.html_id) EscapeAttribute(&branch.html_id)
) )
.unwrap(); .unwrap();
@ -136,7 +153,7 @@ pub fn branch_to_html(
} }
}; };
if branch.attributes.template { if branch.attributes.template {
final_markdown = mini_template::render(config, treehouse, &final_markdown); final_markdown = mini_template::render(config, treehouse, paths, &final_markdown);
} }
let markdown_parser = pulldown_cmark::Parser::new_with_broken_link_callback( let markdown_parser = pulldown_cmark::Parser::new_with_broken_link_callback(
&final_markdown, &final_markdown,
@ -204,7 +221,15 @@ pub fn branch_to_html(
let num_children = branch.children.len(); let num_children = branch.children.len();
for i in 0..num_children { for i in 0..num_children {
let child_id = treehouse.tree.branch(branch_id).children[i]; let child_id = treehouse.tree.branch(branch_id).children[i];
branch_to_html(s, treehouse, config, config_derived_data, file_id, child_id); branch_to_html(
s,
treehouse,
config,
config_derived_data,
paths,
file_id,
child_id,
);
} }
s.push_str("</ul>"); s.push_str("</ul>");
} }
@ -221,12 +246,21 @@ pub fn branches_to_html(
treehouse: &mut Treehouse, treehouse: &mut Treehouse,
config: &Config, config: &Config,
config_derived_data: &mut ConfigDerivedData, config_derived_data: &mut ConfigDerivedData,
paths: &Paths<'_>,
file_id: FileId, file_id: FileId,
branches: &[SemaBranchId], branches: &[SemaBranchId],
) { ) {
s.push_str("<ul>"); s.push_str("<ul>");
for &child in branches { for &child in branches {
branch_to_html(s, treehouse, config, config_derived_data, file_id, child); branch_to_html(
s,
treehouse,
config,
config_derived_data,
paths,
file_id,
child,
);
} }
s.push_str("</ul>"); s.push_str("</ul>");
} }

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Top-level `%%` root attributes. /// Top-level `%%` root attributes.
@ -79,6 +81,14 @@ pub struct Attributes {
/// debug mode. /// debug mode.
#[serde(default)] #[serde(default)]
pub stage: Stage, pub stage: Stage,
/// List of extra spells to cast on the branch.
#[serde(default)]
pub cast: String,
/// List of extra `data` attributes to add to the block.
#[serde(default)]
pub data: HashMap<String, String>,
} }
/// Controls for block content presentation. /// Controls for block content presentation.

View file

@ -8,7 +8,7 @@ use std::ops::Range;
use pulldown_cmark::escape::escape_html; use pulldown_cmark::escape::escape_html;
use crate::{config::Config, state::Treehouse}; use crate::{cli::Paths, config::Config, state::Treehouse};
struct Lexer<'a> { struct Lexer<'a> {
input: &'a str, input: &'a str,
@ -149,7 +149,7 @@ impl<'a> Renderer<'a> {
self.output.push_str(&self.lexer.input[token.range.clone()]); self.output.push_str(&self.lexer.input[token.range.clone()]);
} }
fn render(&mut self, config: &Config, treehouse: &Treehouse) { fn render(&mut self, config: &Config, treehouse: &Treehouse, paths: &Paths<'_>) {
let kind_of = |token: &Token| token.kind; let kind_of = |token: &Token| token.kind;
while let Some(token) = self.lexer.next() { while let Some(token) = self.lexer.next() {
@ -166,6 +166,7 @@ impl<'a> Renderer<'a> {
match Self::render_template( match Self::render_template(
config, config,
treehouse, treehouse,
paths,
self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(), self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(),
) { ) {
Ok(s) => match escaping { Ok(s) => match escaping {
@ -192,22 +193,24 @@ impl<'a> Renderer<'a> {
fn render_template( fn render_template(
config: &Config, config: &Config,
_treehouse: &Treehouse, _treehouse: &Treehouse,
paths: &Paths<'_>,
template: &str, template: &str,
) -> Result<String, InvalidTemplate> { ) -> Result<String, InvalidTemplate> {
let (function, arguments) = template.split_once(' ').unwrap_or((template, "")); let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
match function { match function {
"pic" => Ok(config.pic_url(arguments)), "pic" => Ok(config.pic_url(arguments)),
"c++" => Ok("<script>alert(1)</script>".into()), "include_static" => std::fs::read_to_string(paths.static_dir.join(arguments))
.map_err(|_| InvalidTemplate),
_ => Err(InvalidTemplate), _ => Err(InvalidTemplate),
} }
} }
} }
pub fn render(config: &Config, treehouse: &Treehouse, input: &str) -> String { pub fn render(config: &Config, treehouse: &Treehouse, paths: &Paths<'_>, input: &str) -> String {
let mut renderer = Renderer { let mut renderer = Renderer {
lexer: Lexer::new(input), lexer: Lexer::new(input),
output: String::new(), output: String::new(),
}; };
renderer.render(config, treehouse); renderer.render(config, treehouse, paths);
renderer.output renderer.output
} }

View file

@ -0,0 +1,42 @@
{
"nodes": {
"init": {
"kind": "say",
"content": "hello! nice to meet you.",
"then": "question1"
},
"question1": {
"kind": "ask",
"questions": [
{ "content": "Who are you?", "then": "whoAreYou" },
{ "content": "What is this?", "then": "whatIsThis" }
]
},
"whoAreYou": {
"kind": "say",
"content": "I'm liquidex. y'know, the guy running this place.",
"then": "question1"
},
"whatIsThis": {
"kind": "say",
"content": "this is a test of the treehouse dialogue system. basically, I made myself a little framework for writing conversations between characters, and this is a test page for it.",
"then": "whatIsThisQuestion"
},
"whatIsThisQuestion": {
"kind": "ask",
"questions": [
{ "content": "Right.", "then": "question1" },
{
"content": "\"Dialogues\", seriously? What, are you British?",
"then": "british"
}
]
},
"british": {
"kind": "say",
"content": "no, but I think British is pretty damn funny.",
"then": "question1"
},
"end": { "kind": "end" }
}
}

View file

@ -0,0 +1,83 @@
:root {
--icon-choose: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTYgMTJMMTAgOEw2IDQiIHN0cm9rZT0iIzU1NDIzZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=');
}
@media (prefers-color-scheme: dark) {
:root {
--icon-choose: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTYgMTJMMTAgOEw2IDQiIHN0cm9rZT0iI2Q3Y2RiZiIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=');
}
}
th-chat {
display: flex;
flex-direction: column;
}
th-chat-said {
display: block;
border: 1px solid var(--border-1);
padding: 12px 16px;
margin: 8px 0;
border-radius: 8px;
max-width: 60%;
}
th-chat-asked {
display: flex;
flex-direction: row-reverse;
&>button {
/* Reset <button> */
border: none;
background: none;
color: var(--text-color);
padding: 0.5em 0;
margin-right: 2rem;
--recursive-wght: 500;
text-decoration: underline;
text-align: right;
opacity: 80%;
cursor: pointer;
transition: opacity var(--transition-duration);
&.asked {
display: none;
opacity: 50%;
}
&[disabled] {
--recursive-wght: 600;
cursor: default;
opacity: 100%;
text-decoration: none;
}
&:hover {
opacity: 100%;
}
&::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
margin-right: 0.5em;
background-image: var(--icon-choose);
background-position: 50% 50%;
background-repeat: no-repeat;
opacity: 0%;
vertical-align: middle;
translate: -1em 0;
transition: opacity var(--transition-duration), translate var(--transition-duration);
}
&:hover::before, &[disabled]::before {
opacity: 50%;
translate: 0 0;
}
}
}

View file

@ -101,7 +101,8 @@ body::selection {
body, body,
pre, pre,
code, code,
kbd { kbd,
button {
font-family: 'RecVar', sans-serif; font-family: 'RecVar', sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;

View file

@ -0,0 +1,165 @@
import { addSpell, spell } from "treehouse/spells.js";
import { Branch } from "treehouse/tree.js";
const persistenceKey = "treehouse.chats";
let persistentState = JSON.parse(localStorage.getItem(persistenceKey)) || {};
persistentState.log ??= {};
savePersistentState();
function savePersistentState() {
localStorage.setItem(persistenceKey, JSON.stringify(persistentState));
}
class Chat extends HTMLElement {
constructor(branch) {
super();
this.branch = branch;
}
connectedCallback() {
this.id = spell(this.branch, Branch).namedID;
this.model = JSON.parse(spell(this.branch, Branch).branchContent.textContent);
this.state = new ChatState(this, this.model);
this.state.onInteract = () => {
persistentState.log[this.id] = this.state.log;
savePersistentState();
};
this.state.exec("init");
let log = persistentState.log[this.id];
if (log != null) {
this.state.replay(log);
}
}
}
customElements.define("th-chat", Chat);
class Said extends HTMLElement {
constructor({ content }) {
super();
this.content = content;
}
connectedCallback() {
this.innerHTML = this.content;
this.dispatchEvent(new Event(".animationsComplete"));
}
}
customElements.define("th-chat-said", Said);
class Asked extends HTMLElement {
constructor({ content, alreadyAsked }) {
super();
this.content = content;
this.alreadyAsked = alreadyAsked;
}
connectedCallback() {
this.button = document.createElement("button");
this.button.innerHTML = this.content;
this.button.addEventListener("click", _ => {
this.dispatchEvent(new Event(".click"));
});
if (this.alreadyAsked) {
this.button.classList.add("asked");
}
this.appendChild(this.button);
}
interactionFinished() {
this.button.disabled = true;
}
}
customElements.define("th-chat-asked", Asked);
class ChatState {
constructor(container, model) {
this.container = container;
this.model = model;
this.log = [];
this.results = {};
this.wereAsked = new Set();
this.onInteract = _ => {};
}
replay(log) {
for (let entry of log) {
this.interact(entry);
}
}
exec(name) {
let node = this.model.nodes[name];
let results = this.results[name];
this.results[name] = this[node.kind](name, node, results);
}
say(_, node) {
let said = new Said({ content: node.content });
said.addEventListener(".animationsComplete", _ => this.exec(node.then));
this.container.appendChild(said);
}
ask(name, node) {
let questions = [];
for (let i_ = 0; i_ < node.questions.length; ++i_) {
let i = i_;
let key = `${name}[${i}]`;
let question = node.questions[i];
let asked = new Asked({ content: question.content, alreadyAsked: this.wereAsked.has(key) });
asked.addEventListener(".click", _ => {
this.interact({
kind: "ask.choose",
name,
option: i,
key,
});
});
this.container.appendChild(asked);
questions[i] = asked;
}
return questions;
}
end() {}
interact(interaction) {
let node = this.model.nodes[interaction.name];
this.log.push(interaction);
this.onInteract();
switch (interaction.kind) {
case "ask.choose": {
if (this.wereAsked.has(interaction.key)) {
this.log.pop();
}
this.wereAsked.add(interaction.key);
let questions = this.results[interaction.name];
let question = node.questions[interaction.option];
let asked = questions[interaction.option];
asked.interactionFinished();
this.exec(question.then);
for (let q of questions) {
if (q != asked) {
q.parentNode.removeChild(q);
}
}
}
break;
}
}
}
addSpell("chat", class {
constructor(branch) {
branch.replaceWith(new Chat(branch));
}
});

View file

@ -70,11 +70,13 @@ let mutationObserver = new MutationObserver(records => {
// NOTE: Added nodes may contain children which also need to be processed. // NOTE: Added nodes may contain children which also need to be processed.
// Collect those that have [data-cast] on them and apply spells to them. // Collect those that have [data-cast] on them and apply spells to them.
for (let addedNode of record.addedNodes) { for (let addedNode of record.addedNodes) {
if (!(addedNode instanceof Text)) {
if (addedNode.getAttribute("data-cast") != null) { if (addedNode.getAttribute("data-cast") != null) {
mutatedNodes.add(addedNode); mutatedNodes.add(addedNode);
} }
addedNode.querySelectorAll("[data-cast]").forEach(element => mutatedNodes.add(element)); addedNode.querySelectorAll("[data-cast]").forEach(element => mutatedNodes.add(element));
} }
}
applySpells(mutatedNodes); applySpells(mutatedNodes);
} }
}); });

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="#d7cdbf" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 164 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="#55423e" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 164 B