wip: 47 tiles
This commit is contained in:
parent
1013c53975
commit
ca94c06c5f
|
@ -1,9 +1,12 @@
|
||||||
%% title = "tairu - an interactive exploration of 2D autotiling techniques"
|
%% title = "tairu - an interactive exploration of 2D autotiling techniques"
|
||||||
scripts = [
|
scripts = [
|
||||||
|
"components/literate-programming.js",
|
||||||
"tairu/cardinal-directions.js",
|
"tairu/cardinal-directions.js",
|
||||||
"tairu/framework.js",
|
"tairu/framework.js",
|
||||||
"tairu/tairu.js",
|
"tairu/tairu.js",
|
||||||
"tairu/tilemap-registry.js",
|
"tairu/tilemap-registry.js",
|
||||||
|
"tairu/tilemap.js",
|
||||||
|
"vendor/codejar.js",
|
||||||
]
|
]
|
||||||
styles = ["tairu.css"]
|
styles = ["tairu.css"]
|
||||||
|
|
||||||
|
@ -17,11 +20,11 @@ styles = ["tairu.css"]
|
||||||
- TODO: short videos demoing this here
|
- TODO: short videos demoing this here
|
||||||
|
|
||||||
% id = "01HPD4XQPWJBTJ4DWAQE3J87C9"
|
% id = "01HPD4XQPWJBTJ4DWAQE3J87C9"
|
||||||
- once upon a time I heard of a technique called...\
|
- once upon a time I stumbled upon a technique called...\
|
||||||
**bitwise autotiling**
|
**bitwise autotiling**
|
||||||
|
|
||||||
% id = "01HPD4XQPW6VK3FDW5QRCE6HSS"
|
% id = "01HPD4XQPW6VK3FDW5QRCE6HSS"
|
||||||
+ I learned about it back when I was building 2D Minecraft clones using [Construct 2](https://www.construct.net/en/construct-2/manuals/construct-2), and I wanted my terrain to look nice as it does in Terraria
|
+ I learned about it way back when I was just a kid building 2D Minecraft clones using [Construct 2](https://www.construct.net/en/construct-2/manuals/construct-2), and I wanted my terrain to look nice as it does in Terraria
|
||||||
|
|
||||||
% id = "01HPD4XQPWJ1CE9ZVRW98X7HE6"
|
% id = "01HPD4XQPWJ1CE9ZVRW98X7HE6"
|
||||||
- Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile:
|
- Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile:
|
||||||
|
@ -112,6 +115,10 @@ styles = ["tairu.css"]
|
||||||
|
|
||||||
![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5]
|
![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5]
|
||||||
|
|
||||||
|
% id = "01HPQCCV4RB65D5Q4RANJKGC0D"
|
||||||
|
- **hint:** you can actually just use the original image, but use a lookup table from these indices to (x, y) coordinates.
|
||||||
|
this makes creating the assets a lot easier! (at the expense of some CPU time, though it is totally possible to offload tilemap rendering to the GPU - in that case it barely even matters.)
|
||||||
|
|
||||||
% id = "01HPMVT9BMMEM4HT4ANZ40992P"
|
% id = "01HPMVT9BMMEM4HT4ANZ40992P"
|
||||||
- in JavaScript, drawing on a `<canvas>` using bitwise autotiling would look like this:
|
- in JavaScript, drawing on a `<canvas>` using bitwise autotiling would look like this:
|
||||||
```javascript
|
```javascript
|
||||||
|
@ -193,21 +200,169 @@ styles = ["tairu.css"]
|
||||||
- (I'm totally not trying to say this implementation is an L so far)
|
- (I'm totally not trying to say this implementation is an L so far)
|
||||||
|
|
||||||
% id = "01HPMVT9BMWG6QHQ125Z884W8Z"
|
% id = "01HPMVT9BMWG6QHQ125Z884W8Z"
|
||||||
+ i'll cut right to the chase here and say it outright - the issue is that we simply don't have enough tiles to represent corner cases like this!
|
+ i'll cut right to the chase here and say it outright - the issue is that we simply don't have enough tiles to represent *corner* cases like this!
|
||||||
|
|
||||||
% id = "01HPMVT9BMQK8N1H68YV3J4CFQ"
|
% id = "01HPMVT9BMQK8N1H68YV3J4CFQ"
|
||||||
- see what I did there?
|
- see what I did there?
|
||||||
|
|
||||||
% id = "01HPMVT9BMJTG3KD3K5EJ3BC93"
|
% id = "01HPMVT9BMJTG3KD3K5EJ3BC93"
|
||||||
- the solution here is to introduce more tiles to handle these edge cases.
|
- the solution to that is to introduce more tiles to handle these edge cases.
|
||||||
|
|
||||||
TODO Explain
|
% classes.branch = "tileset-four-to-eight-demo"
|
||||||
|
id = "01HPQCCV4R5N97FJ1GS36HZJZ7"
|
||||||
|
- to represent the corners, we'll turn our four cardinal directions...
|
||||||
|
|
||||||
|
<ul class="directions-square">
|
||||||
|
<li class="east">E</li>
|
||||||
|
<li class="south">S</li>
|
||||||
|
<li class="west">W</li>
|
||||||
|
<li class="north">N</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
into eight *ordinal* directions:
|
||||||
|
|
||||||
|
<ul class="directions-square">
|
||||||
|
<li class="east">E</li>
|
||||||
|
<li class="south-east">SE</li>
|
||||||
|
<li class="south">S</li>
|
||||||
|
<li class="south-west">SW</li>
|
||||||
|
<li class="west">W</li>
|
||||||
|
<li class="north-west">NW</li>
|
||||||
|
<li class="north">N</li>
|
||||||
|
<li class="north-east"><a href="https://github.com/NoiseStudio/NoiseEngine/" title="NoiseEngine????">NE</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
% id = "01HPQCCV4R3GNEWZQFWGWH4Z6R"
|
||||||
|
- you might think that at this point we'll need 8 bits to represent our tiles, and that would make...
|
||||||
|
|
||||||
|
***256 tiles!?***
|
||||||
|
|
||||||
|
nobody in their right mind would actually draw 256 separate tiles, right? ***RIGHT???***
|
||||||
|
|
||||||
|
% template = true
|
||||||
|
id = "01HPQCCV4RX13VR4DJAP2F9PFA"
|
||||||
|
- ...right! if you experiment with the bit combinations, you'll quickly find out that there is no difference if, relative to a single center tile, we have tiles on the corners:
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
is="tairu-editor"
|
||||||
|
data-tilemap-id="bitwiseAutotilingCorners"
|
||||||
|
data-tile-size="40"
|
||||||
|
>
|
||||||
|
Your browser does not support <canvas>.
|
||||||
|
<img class="resource" src="{% pic 01HPMMR6DGKYTPZ9CK0WQWKNX5 %}" data-tairu-tileset="1">
|
||||||
|
</canvas>
|
||||||
|
|
||||||
|
these should all render the same way, despite technically having some [new neighbors](https://en.wikipedia.org/wiki/Moore_neighborhood).
|
||||||
|
|
||||||
|
% classes.branch = "tileset-four-to-eight-demo"
|
||||||
|
id = "01HPQCCV4RHZ8A7VMT2KM7T27P"
|
||||||
|
- what we can do about this is to ignore corners whenever zero or one of the tiles at their cardinal directions is connected -
|
||||||
|
for example, in the case of `E | SE | S`:
|
||||||
|
|
||||||
|
<ul class="directions-square e-s">
|
||||||
|
<li class="east">E</li>
|
||||||
|
<li class="south-east">SE</li>
|
||||||
|
<li class="south">S</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
we can completely ignore what happens in the northeast, northwest, and southwest, because the tile's cardinal directions do not fully contain any of these direction pairs.
|
||||||
|
|
||||||
|
% id = "01HPQCCV4R557T2SN7ES7Z4EJ7"
|
||||||
|
- we can verify this logic with a bit of code; with a bit of luck, we should be able to narrow down our tileset into something a lot more manageable.
|
||||||
|
|
||||||
|
+ we'll start off by defining a bunch of variables to represent our ordinal directions:
|
||||||
|
|
||||||
|
```javascript ordinal-directions
|
||||||
|
const E = 0b00000001;
|
||||||
|
const SE = 0b00000010;
|
||||||
|
const S = 0b00000100;
|
||||||
|
const SW = 0b00001000;
|
||||||
|
const W = 0b00010000;
|
||||||
|
const NW = 0b00100000;
|
||||||
|
const N = 0b01000000;
|
||||||
|
const NE = 0b10000000;
|
||||||
|
const ALL = E | SE | S | SW | W | NW | N | NE;
|
||||||
|
```
|
||||||
|
|
||||||
|
as I've already said, we represent each direction using a single bit.
|
||||||
|
|
||||||
|
- I'm using JavaScript by the way, because it's the native programming language of your web browser. read on to the end of this tangent to see why.
|
||||||
|
|
||||||
|
- now I don't know about you, but I find the usual C-style way of checking whether a bit is set extremely hard to read, so let's take care of that:
|
||||||
|
|
||||||
|
```javascript ordinal-directions
|
||||||
|
function isSet(integer, bit) {
|
||||||
|
return (integer & bit) == bit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- now we can write a function that will remove the aforementioned redundancies.
|
||||||
|
the logic is quite simple - for southeast, we only allow it to be set if both south and east are also set, and so on and so forth.
|
||||||
|
|
||||||
|
```javascript ordinal-directions
|
||||||
|
// t is a tile index; variable name is short for brevity
|
||||||
|
function removeRedundancies(t) {
|
||||||
|
if (isSet(t, SE) && (!isSet(t, S) || !isSet(t, E))) {
|
||||||
|
t &= ~SE;
|
||||||
|
}
|
||||||
|
if (isSet(t, SW) && (!isSet(t, S) || !isSet(t, W))) {
|
||||||
|
t &= ~SW;
|
||||||
|
}
|
||||||
|
if (isSet(t, NW) && (!isSet(t, N) || !isSet(t, W))) {
|
||||||
|
t &= ~NW;
|
||||||
|
}
|
||||||
|
if (isSet(t, NE) && (!isSet(t, N) || !isSet(t, E))) {
|
||||||
|
t &= ~NE;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- with that, we can find a set of all unique non-redundant combinations:
|
||||||
|
|
||||||
|
```javascript ordinal-directions
|
||||||
|
function ordinalDirections() {
|
||||||
|
let unique = new Set();
|
||||||
|
for (let i = 0; i <= ALL; ++i) {
|
||||||
|
unique.add(removeRedundancies(i));
|
||||||
|
}
|
||||||
|
return Array.from(unique).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- by the way, I find it quite funny how JavaScript's [`Array.prototype.sort`] defaults to ASCII ordering *for all types.*
|
||||||
|
even numbers! ain't that silly?
|
||||||
|
|
||||||
|
[`Array.prototype.sort`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
|
||||||
|
|
||||||
|
- and now it's time to _Let It Cook™_:
|
||||||
|
|
||||||
|
```javascript ordinal-directions
|
||||||
|
let dirs = ordinalDirections();
|
||||||
|
console.log(dirs.length);
|
||||||
|
```
|
||||||
|
|
||||||
|
```output ordinal-directions
|
||||||
|
47
|
||||||
|
```
|
||||||
|
|
||||||
|
- forty seven! that's how many unique tiles we actually need.
|
||||||
|
|
||||||
|
- you may find pixel art tutorials saying you need forty *eight* and not forty *seven*, but that is not quite correct -
|
||||||
|
the forty eighth tile is actually just the empty tile! saying it's part of the tileset is quite misleading IMO.
|
||||||
|
|
||||||
|
- phew... the nesting's getting quite unwieldy, let's wrap up this tangent and return back to doing some bitwise autotiling!
|
||||||
|
|
||||||
|
- so in reality we actually only need 47 tiles and not 256 - that's a whole lot less, that's 81.640625% less tiles we have to draw!
|
||||||
|
|
||||||
|
- and it's even possible to autogenerate most of them given just a few smaller 4x4 pieces - but for now, let's not go down that path.\
|
||||||
|
maybe another time.
|
||||||
|
|
||||||
% id = "01HPD4XQPWT9N8X9BD9GKWD78F"
|
% id = "01HPD4XQPWT9N8X9BD9GKWD78F"
|
||||||
- bitwise autotiling is a really cool technique that I've used in plenty of games in the past
|
- bitwise autotiling is a really cool technique that I've used in plenty of games in the past.
|
||||||
|
|
||||||
% id = "01HPD4XQPW5FQY8M04S6JEBDHQ"
|
% id = "01HPD4XQPW5FQY8M04S6JEBDHQ"
|
||||||
- as I mentioned before, [I've known it since my Construct 2 days][branch:01HPD4XQPW6VK3FDW5QRCE6HSS], but when it comes to my released games [Planet Overgamma] would probably be the first to utilize it properly
|
- as I mentioned before, [I've known it since my Construct 2 days][branch:01HPD4XQPW6VK3FDW5QRCE6HSS], but when it comes to any released games [Planet Overgamma] would probably be the first to utilize it properly.
|
||||||
|
|
||||||
TODO video of some Planet Overgamma gameplay showing the autotiling in action
|
TODO video of some Planet Overgamma gameplay showing the autotiling in action
|
||||||
|
|
||||||
|
@ -215,7 +370,7 @@ styles = ["tairu.css"]
|
||||||
|
|
||||||
% id = "01HPJ8GHDEN4XRPT1AJ1BTNTFJ"
|
% id = "01HPJ8GHDEN4XRPT1AJ1BTNTFJ"
|
||||||
- this accursed game has been haunting me for years since; there have been many iterations.
|
- this accursed game has been haunting me for years since; there have been many iterations.
|
||||||
he autotiling source code of the one in the video can be found [here][autotiling source code].
|
the autotiling source code of the one in the video can be found [here][autotiling source code].
|
||||||
|
|
||||||
[autotiling source code]: https://github.com/liquidev/planet-overgamma/blob/classic/jam/map.lua#L209
|
[autotiling source code]: https://github.com/liquidev/planet-overgamma/blob/classic/jam/map.lua#L209
|
||||||
|
|
||||||
|
@ -223,19 +378,20 @@ styles = ["tairu.css"]
|
||||||
+ but one day I found a really cool project called [Tilekit](https://rxi.itch.io/tilekit)
|
+ but one day I found a really cool project called [Tilekit](https://rxi.itch.io/tilekit)
|
||||||
|
|
||||||
% id = "01HPD4XQPW11EQTBDQSGXW3S52"
|
% id = "01HPD4XQPW11EQTBDQSGXW3S52"
|
||||||
+ (of course it's really cool, after all rxi made it)
|
+ (of course it's really cool, after all [rxi](https://github.com/rxi) made it)
|
||||||
|
|
||||||
% id = "01HPD4XQPWYHS327BV586SB085"
|
% id = "01HPD4XQPWYHS327BV586SB085"
|
||||||
- for context rxi is the genius behind the Lua-powered, simple, and modular text editor `lite` that I was using for quite a while
|
- for context rxi is the genius behind the Lua-powered, simple, and modular text editor [lite](https://github.com/rxi/lite) that I was using for quite a while
|
||||||
|
|
||||||
% id = "01HPD4XQPWJ9QAQ5MF2J5JBB8M"
|
% id = "01HPD4XQPWJ9QAQ5MF2J5JBB8M"
|
||||||
- after a while I switched to a fork - Lite XL, which had better font rendering and more features
|
- after a while I switched to a fork - [Lite XL](https://github.com/lite-xl/lite-xl), which had better font rendering and more features
|
||||||
|
|
||||||
% id = "01HPD4XQPWB11TZSX5VAAJ6TCD"
|
% id = "01HPD4XQPWB11TZSX5VAAJ6TCD"
|
||||||
- I stopped using it because VS Code was just more feature packed and usable; no need to reinvent the wheel, rust-analyzer *just works.*
|
- I stopped using it because VS Code was just more feature packed and usable; no need to reinvent the wheel, rust-analyzer *just works.*
|
||||||
|
|
||||||
% id = "01HPD4XQPW3G7BXTBBTD05MB8V"
|
% id = "01HPD4XQPW3G7BXTBBTD05MB8V"
|
||||||
- the LSP plugin for Lite XL had some issues around autocompletions not filling in properly :pensive:\
|
- the LSP plugin for Lite XL had some issues around autocompletions not filling in properly :pensive:
|
||||||
|
|
||||||
it's likely a lot better now, but back then I decided this is too much for my nerves.
|
it's likely a lot better now, but back then I decided this is too much for my nerves.
|
||||||
while tinkering with your editor is something really cool, in my experience it's only cool up to a point.
|
while tinkering with your editor is something really cool, in my experience it's only cool up to a point.
|
||||||
|
|
||||||
|
@ -267,27 +423,10 @@ styles = ["tairu.css"]
|
||||||
% id = "01HPD4XQPWP847T0EAM0FJ88T4"
|
% id = "01HPD4XQPWP847T0EAM0FJ88T4"
|
||||||
- then vines
|
- then vines
|
||||||
|
|
||||||
|
- well... it's even simpler than that in terms of graphical presentation, but we'll get to that.
|
||||||
|
|
||||||
% id = "01HPD4XQPWK58Z63X6962STADR"
|
% id = "01HPD4XQPWK58Z63X6962STADR"
|
||||||
- I mean, after all - bitwise autotiling is basically a clever solution to an `if` complexity problem, so why not extend that with more logic and rules and stuff to let you build more complex maps?
|
- I mean, after all - bitwise autotiling is basically a clever solution to an `if` complexity problem, so why not extend that with more logic and rules and stuff to let you build more complex maps?
|
||||||
|
|
||||||
% id = "01HPJ8GHDFRA2SPNHKJYD0SYPP"
|
% id = "01HPJ8GHDFRA2SPNHKJYD0SYPP"
|
||||||
- of course Tilekit's solution is a lot more simple, streamlined, and user-friendly, but you get the gist.
|
- of course Tilekit's solution is a lot more simple, streamlined, and user-friendly, but you get the gist.
|
||||||
|
|
||||||
% id = "01HPD4XQPW4Y075XWJCT6AATB2"
|
|
||||||
- ever since then I've been wanting to build something just like Tilekit, but in the form of an educational, interactive blog post to demonstrate the ideas in a fun way
|
|
||||||
|
|
||||||
% id = "01HPD4XQPWR8J9WCNBNCTJERZS"
|
|
||||||
- and what you're reading is the result of that.
|
|
||||||
|
|
||||||
% id = "01HPD4XQPW1EP8YHACRJVMA0GM"
|
|
||||||
- so let's get going! first, we'll build a basic tile editor using JavaScript.
|
|
||||||
|
|
||||||
% id = "01HPD4XQPWPNRTVJFNFGNHJMG1"
|
|
||||||
+ not my favorite language, but we're on the Web so it's not like we have much more of a choice.
|
|
||||||
|
|
||||||
% id = "01HPD4XQPWGK7M4XJYC99XE4T6"
|
|
||||||
- I could use TypeScript, but this page follows a philosophy of not introducing complexity where I can deal without it.
|
|
||||||
TypeScript is totally cool, but not necessary.
|
|
||||||
|
|
||||||
% id = "01HPD4XQPWAE0ZH46WME6WJSVP"
|
|
||||||
- I'll be using Web Components (in particular, custom elements) combined with canvas to add stuff onto the page.
|
|
||||||
|
|
|
@ -227,16 +227,29 @@ where
|
||||||
self.write_newline()?;
|
self.write_newline()?;
|
||||||
}
|
}
|
||||||
match info {
|
match info {
|
||||||
CodeBlockKind::Fenced(info) => {
|
CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
|
||||||
let lang = info.split(' ').next().unwrap();
|
CodeBlockMode::PlainText => self.write("<pre><code>"),
|
||||||
if lang.is_empty() {
|
CodeBlockMode::SyntaxHighlightOnly { language } => {
|
||||||
self.write("<pre><code>")
|
|
||||||
} else {
|
|
||||||
self.write("<pre><code class=\"language-")?;
|
self.write("<pre><code class=\"language-")?;
|
||||||
escape_html(&mut self.writer, lang)?;
|
escape_html(&mut self.writer, language)?;
|
||||||
self.write("\">")
|
self.write("\">")
|
||||||
}
|
}
|
||||||
|
CodeBlockMode::LiterateProgram {
|
||||||
|
language,
|
||||||
|
kind,
|
||||||
|
program_name,
|
||||||
|
} => {
|
||||||
|
self.write(match kind {
|
||||||
|
LiterateCodeKind::Input => "<th-literate-editor ",
|
||||||
|
LiterateCodeKind::Output => "<th-literate-output ",
|
||||||
|
})?;
|
||||||
|
self.write("data-program=\"")?;
|
||||||
|
escape_html(&mut self.writer, program_name)?;
|
||||||
|
self.write("\" data-language=\"")?;
|
||||||
|
escape_html(&mut self.writer, language)?;
|
||||||
|
self.write("\" role=\"code\">")
|
||||||
}
|
}
|
||||||
|
},
|
||||||
CodeBlockKind::Indented => self.write("<pre><code>"),
|
CodeBlockKind::Indented => self.write("<pre><code>"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -352,8 +365,21 @@ where
|
||||||
Tag::BlockQuote => {
|
Tag::BlockQuote => {
|
||||||
self.write("</blockquote>\n")?;
|
self.write("</blockquote>\n")?;
|
||||||
}
|
}
|
||||||
Tag::CodeBlock(_) => {
|
Tag::CodeBlock(kind) => {
|
||||||
self.write("</code></pre>\n")?;
|
self.write(match kind {
|
||||||
|
CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
|
||||||
|
CodeBlockMode::LiterateProgram {
|
||||||
|
kind: LiterateCodeKind::Input,
|
||||||
|
..
|
||||||
|
} => "</th-literate-editor>",
|
||||||
|
CodeBlockMode::LiterateProgram {
|
||||||
|
kind: LiterateCodeKind::Output,
|
||||||
|
..
|
||||||
|
} => "</th-literate-output>",
|
||||||
|
_ => "</code></pre>",
|
||||||
|
},
|
||||||
|
_ => "</code></pre>\n",
|
||||||
|
})?;
|
||||||
self.in_code_block = false;
|
self.in_code_block = false;
|
||||||
}
|
}
|
||||||
Tag::List(Some(_)) => {
|
Tag::List(Some(_)) => {
|
||||||
|
@ -518,6 +544,44 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum LiterateCodeKind {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodeBlockMode<'a> {
|
||||||
|
PlainText,
|
||||||
|
SyntaxHighlightOnly {
|
||||||
|
language: &'a str,
|
||||||
|
},
|
||||||
|
LiterateProgram {
|
||||||
|
language: &'a str,
|
||||||
|
kind: LiterateCodeKind,
|
||||||
|
program_name: &'a str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CodeBlockMode<'a> {
|
||||||
|
fn parse(language: &'a str) -> CodeBlockMode<'a> {
|
||||||
|
if language.is_empty() {
|
||||||
|
CodeBlockMode::PlainText
|
||||||
|
} else if let Some((language, program_name)) = language.split_once(' ') {
|
||||||
|
CodeBlockMode::LiterateProgram {
|
||||||
|
language,
|
||||||
|
kind: if language == "output" {
|
||||||
|
LiterateCodeKind::Output
|
||||||
|
} else {
|
||||||
|
LiterateCodeKind::Input
|
||||||
|
},
|
||||||
|
program_name,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CodeBlockMode::SyntaxHighlightOnly { language }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
|
/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
|
||||||
/// push it to a `String`.
|
/// push it to a `String`.
|
||||||
///
|
///
|
||||||
|
|
|
@ -170,7 +170,9 @@ h4 {
|
||||||
|
|
||||||
pre,
|
pre,
|
||||||
code,
|
code,
|
||||||
kbd {
|
kbd,
|
||||||
|
th-literate-editor,
|
||||||
|
th-literate-output {
|
||||||
--recursive-mono: 1.0;
|
--recursive-mono: 1.0;
|
||||||
--recursive-casl: 0.0;
|
--recursive-casl: 0.0;
|
||||||
--recursive-slnt: 0.0;
|
--recursive-slnt: 0.0;
|
||||||
|
@ -210,19 +212,27 @@ body {
|
||||||
|
|
||||||
/* Make code examples a little prettier by giving them visual separation from the rest of the page */
|
/* Make code examples a little prettier by giving them visual separation from the rest of the page */
|
||||||
|
|
||||||
code {
|
code,
|
||||||
|
th-literate-editor {
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
background-color: var(--shaded-background);
|
background-color: var(--shaded-background);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th-literate-editor,
|
||||||
|
th-literate-output {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
border: 1px solid var(--border-1);
|
border: 1px solid var(--border-1);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre,
|
||||||
|
th-literate-editor,
|
||||||
|
th-literate-output {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
background-color: var(--shaded-against-background);
|
background-color: var(--shaded-against-background);
|
||||||
|
@ -231,11 +241,22 @@ pre {
|
||||||
transition: background-color var(--transition-duration);
|
transition: background-color var(--transition-duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree summary:hover pre {
|
th-literate-editor,
|
||||||
background-color: var(--shaded-against-background-twice);
|
th-literate-output {
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre>code {
|
.tree summary:hover {
|
||||||
|
|
||||||
|
& pre,
|
||||||
|
& th-literate-editor,
|
||||||
|
& th-literate-output {
|
||||||
|
background-color: var(--shaded-against-background-twice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre>code,
|
||||||
|
th-literate-output>code {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 0px;
|
border-radius: 0px;
|
||||||
|
@ -493,3 +514,40 @@ img[is="th-emoji"] {
|
||||||
display: inline;
|
display: inline;
|
||||||
animation: 4s hello-there forwards;
|
animation: 4s hello-there forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Literate programming support */
|
||||||
|
|
||||||
|
th-literate-editor {
|
||||||
|
/* Override the cursor with an I-beam, because the editor captures clicks and does not bubble
|
||||||
|
them back up to the caller */
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
th-literate-output {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& code {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& code.error {
|
||||||
|
color: #e39393;
|
||||||
|
}
|
||||||
|
|
||||||
|
& code .return-value {
|
||||||
|
content: 'Return value: ';
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: 'Output';
|
||||||
|
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -63,6 +63,11 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: #d3dce9;
|
color: #d3dce9;
|
||||||
|
text-shadow:
|
||||||
|
1px 0 0 #1a2039,
|
||||||
|
-1px 0 0 #1a2039,
|
||||||
|
0 1px 0 #1a2039,
|
||||||
|
0 -1px 0 #1a2039;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,11 +104,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
& .x-1 {
|
& .x-1 {
|
||||||
background-position-x: 33.3333%;
|
background-position-x: calc(100% / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .x-2 {
|
& .x-2 {
|
||||||
background-position-x: 66.6666%;
|
background-position-x: calc(200% / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .x-3 {
|
& .x-3 {
|
||||||
|
@ -115,14 +120,102 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
& .y-1 {
|
& .y-1 {
|
||||||
background-position-y: 33.3333%;
|
background-position-y: calc(100% / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .y-2 {
|
& .y-2 {
|
||||||
background-position-y: 66.6666%;
|
background-position-y: calc(200% / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .y-3 {
|
& .y-3 {
|
||||||
background-position-y: 100%;
|
background-position-y: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tileset-four-to-eight-demo th-bc {
|
||||||
|
& .directions-square {
|
||||||
|
--recursive-wght: 900;
|
||||||
|
--recursive-casl: 0.0;
|
||||||
|
--recursive-slnt: 0.0;
|
||||||
|
--recursive-mono: 1.0;
|
||||||
|
color: #d3dce9;
|
||||||
|
text-shadow:
|
||||||
|
1px 0 0 #1a2039,
|
||||||
|
-1px 0 0 #1a2039,
|
||||||
|
0 1px 0 #1a2039,
|
||||||
|
0 -1px 0 #1a2039;
|
||||||
|
|
||||||
|
margin-block: 8px;
|
||||||
|
margin-left: 16px;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
|
||||||
|
background-image: url('../pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png');
|
||||||
|
background-size: 400%;
|
||||||
|
background-position: 100% 100%;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 2px 4px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #d3dce9;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .east {
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .south-east {
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .west {
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .south-west {
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .north {
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .north-west {
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .south {
|
||||||
|
left: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .north-east {
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.e-s {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
135
static/js/components/literate-programming.js
Normal file
135
static/js/components/literate-programming.js
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import { CodeJar } from "../vendor/codejar.js";
|
||||||
|
|
||||||
|
let literatePrograms = new Map();
|
||||||
|
|
||||||
|
function getLiterateProgram(name) {
|
||||||
|
if (literatePrograms.get(name) == null) {
|
||||||
|
literatePrograms.set(name, {
|
||||||
|
editors: [],
|
||||||
|
onChanged: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return literatePrograms.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLiterateProgramSourceCode(name) {
|
||||||
|
let sources = [];
|
||||||
|
let literateProgram = getLiterateProgram(name);
|
||||||
|
for (let editor of literateProgram.editors) {
|
||||||
|
sources.push(editor.textContent);
|
||||||
|
}
|
||||||
|
return sources.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiterateEditor extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.literateProgramName = this.getAttribute("data-program");
|
||||||
|
getLiterateProgram(this.literateProgramName).editors.push(this);
|
||||||
|
|
||||||
|
this.codeJar = CodeJar(this, LiterateEditor.highlight);
|
||||||
|
this.codeJar.onUpdate(() => {
|
||||||
|
let literateProgram = getLiterateProgram(this.literateProgramName);
|
||||||
|
for (let handler of literateProgram.onChanged) {
|
||||||
|
handler(this.literateProgramName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.addEventListener("click", event => event.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
static highlight(editor) {
|
||||||
|
// TODO: Syntax highlighting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("th-literate-editor", LiterateEditor);
|
||||||
|
|
||||||
|
function debounce(callback, timeout) {
|
||||||
|
let timeoutId = 0;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeoutId = window.setTimeout(() => callback(...args), timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiterateOutput extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.clearResultsOnNextOutput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.literateProgramName = this.getAttribute("data-program");
|
||||||
|
this.evaluate();
|
||||||
|
|
||||||
|
getLiterateProgram(this.literateProgramName).onChanged.push(_ => this.evaluate());
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate = () => {
|
||||||
|
// This is a small bit of debouncing. If we cleared the output right away, the page would
|
||||||
|
// jitter around irritatingly
|
||||||
|
this.clearResultsOnNextOutput = true;
|
||||||
|
|
||||||
|
if (this.worker != null) {
|
||||||
|
this.worker.terminate();
|
||||||
|
}
|
||||||
|
this.worker = new Worker(`${TREEHOUSE_SITE}/static/js/components/literate-programming/worker.js`, {
|
||||||
|
type: "module",
|
||||||
|
name: `evaluate LiterateOutput ${this.literateProgramName}`
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.addEventListener("message", event => {
|
||||||
|
let message = event.data;
|
||||||
|
if (message.kind == "evalComplete") {
|
||||||
|
this.worker.terminate();
|
||||||
|
} else if (message.kind == "output") {
|
||||||
|
this.addOutput(message.output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
|
action: "eval",
|
||||||
|
input: getLiterateProgramSourceCode(this.literateProgramName),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addOutput(output) {
|
||||||
|
if (this.clearResultsOnNextOutput) {
|
||||||
|
this.clearResultsOnNextOutput = false;
|
||||||
|
this.clearResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show anything if the function didn't return a value.
|
||||||
|
if (output.kind == "result" && output.message[0] === undefined) return;
|
||||||
|
|
||||||
|
let line = document.createElement("code");
|
||||||
|
|
||||||
|
line.classList.add("output");
|
||||||
|
line.classList.add(output.kind);
|
||||||
|
|
||||||
|
line.textContent = output.message.map(x => {
|
||||||
|
if (typeof x === "object") return JSON.stringify(x);
|
||||||
|
else return x + "";
|
||||||
|
}).join(" ");
|
||||||
|
|
||||||
|
if (output.kind == "result") {
|
||||||
|
let returnValueText = document.createElement("span");
|
||||||
|
returnValueText.classList.add("return-value");
|
||||||
|
returnValueText.textContent = "Return value: ";
|
||||||
|
line.insertBefore(returnValueText, line.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appendChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearResults() {
|
||||||
|
this.replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("th-literate-output", LiterateOutput);
|
40
static/js/components/literate-programming/worker.js
Normal file
40
static/js/components/literate-programming/worker.js
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
console = {
|
||||||
|
log(...message) {
|
||||||
|
postMessage({
|
||||||
|
kind: "output",
|
||||||
|
output: {
|
||||||
|
kind: "log",
|
||||||
|
message: [...message],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListener("message", event => {
|
||||||
|
let message = event.data;
|
||||||
|
if (message.action == "eval") {
|
||||||
|
try {
|
||||||
|
let func = new Function(message.input);
|
||||||
|
let result = func.apply({});
|
||||||
|
postMessage({
|
||||||
|
kind: "output",
|
||||||
|
output: {
|
||||||
|
kind: "result",
|
||||||
|
message: [result],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
postMessage({
|
||||||
|
kind: "output",
|
||||||
|
output: {
|
||||||
|
kind: "error",
|
||||||
|
message: [error.toString()],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage({
|
||||||
|
kind: "evalComplete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
|
@ -29,5 +29,12 @@ export default {
|
||||||
" xxx ",
|
" xxx ",
|
||||||
" ",
|
" ",
|
||||||
]),
|
]),
|
||||||
|
bitwiseAutotilingCorners: parseTilemap([
|
||||||
|
" ",
|
||||||
|
" x x ",
|
||||||
|
" x ",
|
||||||
|
" x x ",
|
||||||
|
" ",
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
511
static/js/vendor/codejar.js
vendored
Normal file
511
static/js/vendor/codejar.js
vendored
Normal file
|
@ -0,0 +1,511 @@
|
||||||
|
const globalWindow = window;
|
||||||
|
export function CodeJar(editor, highlight, opt = {}) {
|
||||||
|
const options = {
|
||||||
|
tab: '\t',
|
||||||
|
indentOn: /[({\[]$/,
|
||||||
|
moveToNewLine: /^[)}\]]/,
|
||||||
|
spellcheck: false,
|
||||||
|
catchTab: true,
|
||||||
|
preserveIdent: true,
|
||||||
|
addClosing: true,
|
||||||
|
history: true,
|
||||||
|
window: globalWindow,
|
||||||
|
...opt,
|
||||||
|
};
|
||||||
|
const window = options.window;
|
||||||
|
const document = window.document;
|
||||||
|
const listeners = [];
|
||||||
|
const history = [];
|
||||||
|
let at = -1;
|
||||||
|
let focus = false;
|
||||||
|
let onUpdate = () => void 0;
|
||||||
|
let prev; // code content prior keydown event
|
||||||
|
editor.setAttribute('contenteditable', 'plaintext-only');
|
||||||
|
editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
|
||||||
|
editor.style.outline = 'none';
|
||||||
|
editor.style.overflowWrap = 'break-word';
|
||||||
|
editor.style.overflowY = 'auto';
|
||||||
|
editor.style.whiteSpace = 'pre-wrap';
|
||||||
|
const doHighlight = (editor, pos) => {
|
||||||
|
highlight(editor, pos);
|
||||||
|
};
|
||||||
|
let isLegacy = false; // true if plaintext-only is not supported
|
||||||
|
if (editor.contentEditable !== 'plaintext-only')
|
||||||
|
isLegacy = true;
|
||||||
|
if (isLegacy)
|
||||||
|
editor.setAttribute('contenteditable', 'true');
|
||||||
|
const debounceHighlight = debounce(() => {
|
||||||
|
const pos = save();
|
||||||
|
doHighlight(editor, pos);
|
||||||
|
restore(pos);
|
||||||
|
}, 30);
|
||||||
|
let recording = false;
|
||||||
|
const shouldRecord = (event) => {
|
||||||
|
return !isUndo(event) && !isRedo(event)
|
||||||
|
&& event.key !== 'Meta'
|
||||||
|
&& event.key !== 'Control'
|
||||||
|
&& event.key !== 'Alt'
|
||||||
|
&& !event.key.startsWith('Arrow');
|
||||||
|
};
|
||||||
|
const debounceRecordHistory = debounce((event) => {
|
||||||
|
if (shouldRecord(event)) {
|
||||||
|
recordHistory();
|
||||||
|
recording = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
const on = (type, fn) => {
|
||||||
|
listeners.push([type, fn]);
|
||||||
|
editor.addEventListener(type, fn);
|
||||||
|
};
|
||||||
|
on('keydown', event => {
|
||||||
|
if (event.defaultPrevented)
|
||||||
|
return;
|
||||||
|
prev = toString();
|
||||||
|
if (options.preserveIdent)
|
||||||
|
handleNewLine(event);
|
||||||
|
else
|
||||||
|
legacyNewLineFix(event);
|
||||||
|
if (options.catchTab)
|
||||||
|
handleTabCharacters(event);
|
||||||
|
if (options.addClosing)
|
||||||
|
handleSelfClosingCharacters(event);
|
||||||
|
if (options.history) {
|
||||||
|
handleUndoRedo(event);
|
||||||
|
if (shouldRecord(event) && !recording) {
|
||||||
|
recordHistory();
|
||||||
|
recording = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isLegacy && !isCopy(event))
|
||||||
|
restore(save());
|
||||||
|
});
|
||||||
|
on('keyup', event => {
|
||||||
|
if (event.defaultPrevented)
|
||||||
|
return;
|
||||||
|
if (event.isComposing)
|
||||||
|
return;
|
||||||
|
if (prev !== toString())
|
||||||
|
debounceHighlight();
|
||||||
|
debounceRecordHistory(event);
|
||||||
|
onUpdate(toString());
|
||||||
|
});
|
||||||
|
on('focus', _event => {
|
||||||
|
focus = true;
|
||||||
|
});
|
||||||
|
on('blur', _event => {
|
||||||
|
focus = false;
|
||||||
|
});
|
||||||
|
on('paste', event => {
|
||||||
|
recordHistory();
|
||||||
|
handlePaste(event);
|
||||||
|
recordHistory();
|
||||||
|
onUpdate(toString());
|
||||||
|
});
|
||||||
|
on('cut', event => {
|
||||||
|
recordHistory();
|
||||||
|
handleCut(event);
|
||||||
|
recordHistory();
|
||||||
|
onUpdate(toString());
|
||||||
|
});
|
||||||
|
function save() {
|
||||||
|
const s = getSelection();
|
||||||
|
const pos = { start: 0, end: 0, dir: undefined };
|
||||||
|
let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
|
||||||
|
if (!anchorNode || !focusNode)
|
||||||
|
throw 'error1';
|
||||||
|
// If the anchor and focus are the editor element, return either a full
|
||||||
|
// highlight or a start/end cursor position depending on the selection
|
||||||
|
if (anchorNode === editor && focusNode === editor) {
|
||||||
|
pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
|
||||||
|
pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
|
||||||
|
pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
// Selection anchor and focus are expected to be text nodes,
|
||||||
|
// so normalize them.
|
||||||
|
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const node = document.createTextNode('');
|
||||||
|
anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
|
||||||
|
anchorNode = node;
|
||||||
|
anchorOffset = 0;
|
||||||
|
}
|
||||||
|
if (focusNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const node = document.createTextNode('');
|
||||||
|
focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
|
||||||
|
focusNode = node;
|
||||||
|
focusOffset = 0;
|
||||||
|
}
|
||||||
|
visit(editor, el => {
|
||||||
|
if (el === anchorNode && el === focusNode) {
|
||||||
|
pos.start += anchorOffset;
|
||||||
|
pos.end += focusOffset;
|
||||||
|
pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
|
||||||
|
return 'stop';
|
||||||
|
}
|
||||||
|
if (el === anchorNode) {
|
||||||
|
pos.start += anchorOffset;
|
||||||
|
if (!pos.dir) {
|
||||||
|
pos.dir = '->';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 'stop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (el === focusNode) {
|
||||||
|
pos.end += focusOffset;
|
||||||
|
if (!pos.dir) {
|
||||||
|
pos.dir = '<-';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 'stop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (el.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (pos.dir != '->')
|
||||||
|
pos.start += el.nodeValue.length;
|
||||||
|
if (pos.dir != '<-')
|
||||||
|
pos.end += el.nodeValue.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
editor.normalize(); // collapse empty text nodes
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
function restore(pos) {
|
||||||
|
const s = getSelection();
|
||||||
|
let startNode, startOffset = 0;
|
||||||
|
let endNode, endOffset = 0;
|
||||||
|
if (!pos.dir)
|
||||||
|
pos.dir = '->';
|
||||||
|
if (pos.start < 0)
|
||||||
|
pos.start = 0;
|
||||||
|
if (pos.end < 0)
|
||||||
|
pos.end = 0;
|
||||||
|
// Flip start and end if the direction reversed
|
||||||
|
if (pos.dir == '<-') {
|
||||||
|
const { start, end } = pos;
|
||||||
|
pos.start = end;
|
||||||
|
pos.end = start;
|
||||||
|
}
|
||||||
|
let current = 0;
|
||||||
|
visit(editor, el => {
|
||||||
|
if (el.nodeType !== Node.TEXT_NODE)
|
||||||
|
return;
|
||||||
|
const len = (el.nodeValue || '').length;
|
||||||
|
if (current + len > pos.start) {
|
||||||
|
if (!startNode) {
|
||||||
|
startNode = el;
|
||||||
|
startOffset = pos.start - current;
|
||||||
|
}
|
||||||
|
if (current + len > pos.end) {
|
||||||
|
endNode = el;
|
||||||
|
endOffset = pos.end - current;
|
||||||
|
return 'stop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current += len;
|
||||||
|
});
|
||||||
|
if (!startNode)
|
||||||
|
startNode = editor, startOffset = editor.childNodes.length;
|
||||||
|
if (!endNode)
|
||||||
|
endNode = editor, endOffset = editor.childNodes.length;
|
||||||
|
// Flip back the selection
|
||||||
|
if (pos.dir == '<-') {
|
||||||
|
[startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// If nodes not editable, create a text node.
|
||||||
|
const startEl = uneditable(startNode);
|
||||||
|
if (startEl) {
|
||||||
|
const node = document.createTextNode('');
|
||||||
|
startEl.parentNode?.insertBefore(node, startEl);
|
||||||
|
startNode = node;
|
||||||
|
startOffset = 0;
|
||||||
|
}
|
||||||
|
const endEl = uneditable(endNode);
|
||||||
|
if (endEl) {
|
||||||
|
const node = document.createTextNode('');
|
||||||
|
endEl.parentNode?.insertBefore(node, endEl);
|
||||||
|
endNode = node;
|
||||||
|
endOffset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
|
||||||
|
editor.normalize(); // collapse empty text nodes
|
||||||
|
}
|
||||||
|
function uneditable(node) {
|
||||||
|
while (node && node !== editor) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = node;
|
||||||
|
if (el.getAttribute('contenteditable') == 'false') {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function beforeCursor() {
|
||||||
|
const s = getSelection();
|
||||||
|
const r0 = s.getRangeAt(0);
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(editor);
|
||||||
|
r.setEnd(r0.startContainer, r0.startOffset);
|
||||||
|
return r.toString();
|
||||||
|
}
|
||||||
|
function afterCursor() {
|
||||||
|
const s = getSelection();
|
||||||
|
const r0 = s.getRangeAt(0);
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(editor);
|
||||||
|
r.setStart(r0.endContainer, r0.endOffset);
|
||||||
|
return r.toString();
|
||||||
|
}
|
||||||
|
function handleNewLine(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
const before = beforeCursor();
|
||||||
|
const after = afterCursor();
|
||||||
|
let [padding] = findPadding(before);
|
||||||
|
let newLinePadding = padding;
|
||||||
|
// If last symbol is "{" ident new line
|
||||||
|
if (options.indentOn.test(before)) {
|
||||||
|
newLinePadding += options.tab;
|
||||||
|
}
|
||||||
|
// Preserve padding
|
||||||
|
if (newLinePadding.length > 0) {
|
||||||
|
preventDefault(event);
|
||||||
|
event.stopPropagation();
|
||||||
|
insert('\n' + newLinePadding);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
legacyNewLineFix(event);
|
||||||
|
}
|
||||||
|
// Place adjacent "}" on next line
|
||||||
|
if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
|
||||||
|
const pos = save();
|
||||||
|
insert('\n' + padding);
|
||||||
|
restore(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function legacyNewLineFix(event) {
|
||||||
|
// Firefox does not support plaintext-only mode
|
||||||
|
// and puts <div><br></div> on Enter. Let's help.
|
||||||
|
if (isLegacy && event.key === 'Enter') {
|
||||||
|
preventDefault(event);
|
||||||
|
event.stopPropagation();
|
||||||
|
if (afterCursor() == '') {
|
||||||
|
insert('\n ');
|
||||||
|
const pos = save();
|
||||||
|
pos.start = --pos.end;
|
||||||
|
restore(pos);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
insert('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleSelfClosingCharacters(event) {
|
||||||
|
const open = `([{'"`;
|
||||||
|
const close = `)]}'"`;
|
||||||
|
if (open.includes(event.key)) {
|
||||||
|
preventDefault(event);
|
||||||
|
const pos = save();
|
||||||
|
const wrapText = pos.start == pos.end ? '' : getSelection().toString();
|
||||||
|
const text = event.key + wrapText + close[open.indexOf(event.key)];
|
||||||
|
insert(text);
|
||||||
|
pos.start++;
|
||||||
|
pos.end++;
|
||||||
|
restore(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleTabCharacters(event) {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
preventDefault(event);
|
||||||
|
if (event.shiftKey) {
|
||||||
|
const before = beforeCursor();
|
||||||
|
let [padding, start] = findPadding(before);
|
||||||
|
if (padding.length > 0) {
|
||||||
|
const pos = save();
|
||||||
|
// Remove full length tab or just remaining padding
|
||||||
|
const len = Math.min(options.tab.length, padding.length);
|
||||||
|
restore({ start, end: start + len });
|
||||||
|
document.execCommand('delete');
|
||||||
|
pos.start -= len;
|
||||||
|
pos.end -= len;
|
||||||
|
restore(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
insert(options.tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleUndoRedo(event) {
|
||||||
|
if (isUndo(event)) {
|
||||||
|
preventDefault(event);
|
||||||
|
at--;
|
||||||
|
const record = history[at];
|
||||||
|
if (record) {
|
||||||
|
editor.innerHTML = record.html;
|
||||||
|
restore(record.pos);
|
||||||
|
}
|
||||||
|
if (at < 0)
|
||||||
|
at = 0;
|
||||||
|
}
|
||||||
|
if (isRedo(event)) {
|
||||||
|
preventDefault(event);
|
||||||
|
at++;
|
||||||
|
const record = history[at];
|
||||||
|
if (record) {
|
||||||
|
editor.innerHTML = record.html;
|
||||||
|
restore(record.pos);
|
||||||
|
}
|
||||||
|
if (at >= history.length)
|
||||||
|
at--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function recordHistory() {
|
||||||
|
if (!focus)
|
||||||
|
return;
|
||||||
|
const html = editor.innerHTML;
|
||||||
|
const pos = save();
|
||||||
|
const lastRecord = history[at];
|
||||||
|
if (lastRecord) {
|
||||||
|
if (lastRecord.html === html
|
||||||
|
&& lastRecord.pos.start === pos.start
|
||||||
|
&& lastRecord.pos.end === pos.end)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
at++;
|
||||||
|
history[at] = { html, pos };
|
||||||
|
history.splice(at + 1);
|
||||||
|
const maxHistory = 300;
|
||||||
|
if (at > maxHistory) {
|
||||||
|
at = maxHistory;
|
||||||
|
history.splice(0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handlePaste(event) {
|
||||||
|
if (event.defaultPrevented)
|
||||||
|
return;
|
||||||
|
preventDefault(event);
|
||||||
|
const originalEvent = event.originalEvent ?? event;
|
||||||
|
const text = originalEvent.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n');
|
||||||
|
const pos = save();
|
||||||
|
insert(text);
|
||||||
|
doHighlight(editor);
|
||||||
|
restore({
|
||||||
|
start: Math.min(pos.start, pos.end) + text.length,
|
||||||
|
end: Math.min(pos.start, pos.end) + text.length,
|
||||||
|
dir: '<-',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function handleCut(event) {
|
||||||
|
const pos = save();
|
||||||
|
const selection = getSelection();
|
||||||
|
const originalEvent = event.originalEvent ?? event;
|
||||||
|
originalEvent.clipboardData.setData('text/plain', selection.toString());
|
||||||
|
document.execCommand('delete');
|
||||||
|
doHighlight(editor);
|
||||||
|
restore({
|
||||||
|
start: Math.min(pos.start, pos.end),
|
||||||
|
end: Math.min(pos.start, pos.end),
|
||||||
|
dir: '<-',
|
||||||
|
});
|
||||||
|
preventDefault(event);
|
||||||
|
}
|
||||||
|
function visit(editor, visitor) {
|
||||||
|
const queue = [];
|
||||||
|
if (editor.firstChild)
|
||||||
|
queue.push(editor.firstChild);
|
||||||
|
let el = queue.pop();
|
||||||
|
while (el) {
|
||||||
|
if (visitor(el) === 'stop')
|
||||||
|
break;
|
||||||
|
if (el.nextSibling)
|
||||||
|
queue.push(el.nextSibling);
|
||||||
|
if (el.firstChild)
|
||||||
|
queue.push(el.firstChild);
|
||||||
|
el = queue.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isCtrl(event) {
|
||||||
|
return event.metaKey || event.ctrlKey;
|
||||||
|
}
|
||||||
|
function isUndo(event) {
|
||||||
|
return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
|
||||||
|
}
|
||||||
|
function isRedo(event) {
|
||||||
|
return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
|
||||||
|
}
|
||||||
|
function isCopy(event) {
|
||||||
|
return isCtrl(event) && getKeyCode(event) === 'C';
|
||||||
|
}
|
||||||
|
function getKeyCode(event) {
|
||||||
|
let key = event.key || event.keyCode || event.which;
|
||||||
|
if (!key)
|
||||||
|
return undefined;
|
||||||
|
return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase();
|
||||||
|
}
|
||||||
|
function insert(text) {
|
||||||
|
text = text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
document.execCommand('insertHTML', false, text);
|
||||||
|
}
|
||||||
|
function debounce(cb, wait) {
|
||||||
|
let timeout = 0;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = window.setTimeout(() => cb(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function findPadding(text) {
|
||||||
|
// Find beginning of previous line.
|
||||||
|
let i = text.length - 1;
|
||||||
|
while (i >= 0 && text[i] !== '\n')
|
||||||
|
i--;
|
||||||
|
i++;
|
||||||
|
// Find padding of the line.
|
||||||
|
let j = i;
|
||||||
|
while (j < text.length && /[ \t]/.test(text[j]))
|
||||||
|
j++;
|
||||||
|
return [text.substring(i, j) || '', i, j];
|
||||||
|
}
|
||||||
|
function toString() {
|
||||||
|
return editor.textContent || '';
|
||||||
|
}
|
||||||
|
function preventDefault(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
function getSelection() {
|
||||||
|
if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
|
||||||
|
return editor.parentNode.getSelection();
|
||||||
|
}
|
||||||
|
return window.getSelection();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updateOptions(newOptions) {
|
||||||
|
Object.assign(options, newOptions);
|
||||||
|
},
|
||||||
|
updateCode(code) {
|
||||||
|
editor.textContent = code;
|
||||||
|
doHighlight(editor);
|
||||||
|
onUpdate(code);
|
||||||
|
},
|
||||||
|
onUpdate(callback) {
|
||||||
|
onUpdate = callback;
|
||||||
|
},
|
||||||
|
toString,
|
||||||
|
save,
|
||||||
|
restore,
|
||||||
|
recordHistory,
|
||||||
|
destroy() {
|
||||||
|
for (let [type, fn] of listeners) {
|
||||||
|
editor.removeEventListener(type, fn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 313 B |
Binary file not shown.
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 311 B |
Binary file not shown.
After Width: | Height: | Size: 554 B |
Loading…
Reference in a new issue