diff --git a/README.md b/README.md
index a3d30a70..a27ff01a 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,18 @@ Styling the output is entirely up to you.
{!! tiptap_converter()->asText($post->content) !!}
```
+#### Table of Contents
+
+If you are using the `heading` tool in your editor you can also generate a table of contents from the headings in the content. This is done by passing the content to the `asHTML()` method and setting the `toc` option to `true`. You can also pass a `maxDepth` option to limit the depth of headings to include in the table of contents.
+
+```blade
+
+{!! tiptap_converter()->asHTML($post->content, toc: true, maxDepth: 3) !!}
+
+
+{!! tiptap_converter()->asToc($post->content, maxDepth: 3) !!}
+```
+
## Config
The plugin will work without publishing the config, but should you need to change any of the default settings you can publish the config file with the following Artisan command:
diff --git a/resources/views/table-of-contents.blade.php b/resources/views/table-of-contents.blade.php
new file mode 100644
index 00000000..fa0affe4
--- /dev/null
+++ b/resources/views/table-of-contents.blade.php
@@ -0,0 +1,19 @@
+
+ @foreach ($headings as $heading)
+ @if (array_key_exists($loop->index + 1, $headings) && $headings[$loop->index + 1]['level'] === $heading['level'])
+ -
+ {{ $heading['text'] }}
+
+ @else
+ @endif
+ -
+ @if ($heading[$loop->index + 1] && $heading[$loop->index + 1]['level'] > $heading['level'])
+
+ @endif
+ {{ $heading['text'] }}
+ @if ($heading[$loop->index + 1] && $heading[$loop->index + 1]['level'] > $heading['level'])
+
+ @endforeach
+
\ No newline at end of file
diff --git a/src/Extensions/Extensions/ClassExtension.php b/src/Extensions/Extensions/ClassExtension.php
index be66a567..5456bab0 100644
--- a/src/Extensions/Extensions/ClassExtension.php
+++ b/src/Extensions/Extensions/ClassExtension.php
@@ -34,7 +34,7 @@ public function addGlobalAttributes(): array
return InlineStyle::getAttribute($DOMNode, 'class') ?? false;
},
'renderHTML' => function ($attributes) {
- if (! $attributes->class) {
+ if (! property_exists($attributes, 'class')) {
return null;
}
diff --git a/src/Extensions/Extensions/IdExtension.php b/src/Extensions/Extensions/IdExtension.php
index 923b3da0..226d42b6 100644
--- a/src/Extensions/Extensions/IdExtension.php
+++ b/src/Extensions/Extensions/IdExtension.php
@@ -21,10 +21,10 @@ public function addGlobalAttributes(): array
'id' => [
'default' => null,
'parseHTML' => function ($DOMNode) {
- return InlineStyle::getAttribute($DOMNode, 'id') ?? false;
+ return InlineStyle::getAttribute($DOMNode, 'id');
},
'renderHTML' => function ($attributes) {
- if (! $attributes->id) {
+ if (! property_exists($attributes, 'id')) {
return null;
}
diff --git a/src/TiptapConverter.php b/src/TiptapConverter.php
index 50075fb2..b43627ab 100644
--- a/src/TiptapConverter.php
+++ b/src/TiptapConverter.php
@@ -5,6 +5,8 @@
use FilamentTiptapEditor\Extensions\Extensions;
use FilamentTiptapEditor\Extensions\Marks;
use FilamentTiptapEditor\Extensions\Nodes;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Str;
use Tiptap\Editor;
use Tiptap\Extensions\StarterKit;
use Tiptap\Marks\Highlight;
@@ -24,6 +26,8 @@ class TiptapConverter
protected ?array $blocks = null;
+ protected bool $tableOfContents = false;
+
public function getEditor(): Editor
{
return $this->editor ??= new Editor([
@@ -88,20 +92,123 @@ public function getExtensions(): array
];
}
- public function asHTML(string | array $content): string
+ public function asHTML(string | array $content, bool $toc = false, int $maxDepth = 3): string
{
- return $this->getEditor()->setContent($content)->getHTML();
+ $editor = $this->getEditor()->setContent($content);
+
+ if ($toc) {
+ $this->parseHeadings($editor, $maxDepth);
+ }
+
+ return $editor->getHTML();
}
- public function asJSON(string | array $content, bool $decoded = false): string | array
+ public function asJSON(string | array $content, bool $decoded = false, bool $toc = false, int $maxDepth = 3): string | array
{
- $content = $this->getEditor()->setContent($content)->getJSON();
+ $editor = $this->getEditor()->setContent($content);
+
+ if ($toc) {
+ $this->parseHeadings($editor, $maxDepth);
+ }
- return $decoded ? json_decode($content, true) : $content;
+ return $decoded ? json_decode($editor->getJSON(), true) : $editor->getJSON();
}
public function asText(string | array $content): string
{
return $this->getEditor()->setContent($content)->getText();
}
+
+ public function asTOC(string | array $content, int $maxDepth = 3): string
+ {
+ if (is_string($content)) {
+ $content = $this->asJSON($content, decoded: true);
+ }
+
+ $headings = $this->parseTocHeadings($content['content'], $maxDepth);
+
+ return $this->generateNestedTOC($headings, $headings[0]['level']);
+ }
+
+ public function parseHeadings(Editor $editor, int $maxDepth = 3): Editor
+ {
+ $editor->descendants(function (&$node) use ($maxDepth) {
+ if ($node->type !== 'heading') {
+ return;
+ }
+
+ if ($node->attrs->level > $maxDepth) {
+ return;
+ }
+
+ if (! property_exists($node->attrs, 'id') || $node->attrs->id === null) {
+ $node->attrs->id = str(collect($node->content)->map(function ($node) {
+ return $node->text;
+ })->implode(' '))->kebab()->toString();
+ }
+
+ array_unshift($node->content, (object) [
+ "type" => "text",
+ "text" => "#",
+ "marks" => [
+ [
+ "type" => "link",
+ "attrs" => [
+ "href" => "#" . $node->attrs->id,
+ ]
+ ]
+ ]
+ ]);
+ });
+
+ return $editor;
+ }
+
+ public function parseTocHeadings(array $content, int $maxDepth = 3): array
+ {
+ $headings = [];
+
+ foreach ($content as $node) {
+ if ($node['type'] === 'heading') {
+ if ($node['attrs']['level'] <= $maxDepth) {
+ $text = collect($node['content'])->map(function ($node) {
+ return $node['text'];
+ })->implode(' ');
+
+ if (!isset($node['attrs']['id'])) {
+ $node['attrs']['id'] = str($text)->kebab()->toString();
+ }
+
+ $headings[] = [
+ 'level' => $node['attrs']['level'],
+ 'id' => $node['attrs']['id'],
+ 'text' => $text,
+ ];
+ }
+ } elseif (array_key_exists('content', $content)) {
+ $this->parseTocHeadings($content, $maxDepth);
+ }
+ }
+
+ return $headings;
+ }
+
+ public function generateNestedTOC(array $headings, int $parentLevel = 0): string
+ {
+ $result = '';
+ $prev = $parentLevel;
+
+ foreach ($headings as $item) {
+ $prev <= $item['level'] ?: $result .= str_repeat('
', $prev - $item['level']);
+ $prev >= $item['level'] ?: $result .= '';
+
+ return $result;
+ }
}
diff --git a/src/TiptapFaker.php b/src/TiptapFaker.php
index 49b368bd..83db53e7 100644
--- a/src/TiptapFaker.php
+++ b/src/TiptapFaker.php
@@ -28,6 +28,14 @@ public function heading(int | string | null $level = 2): static
return $this;
}
+ public function headingWithLink(int | string | null $level = 2): static
+ {
+ $heading = $this->faker->words(rand(2, 3), true) . '' . $this->faker->words(rand(2, 3), true) . '' . $this->faker->words(rand(2, 3), true);
+ $this->output .= '' . Str::title($heading) . '';
+
+ return $this;
+ }
+
public function emptyParagraph(): static
{
$this->output .= '';