@threadlabs/looma 0.23.21 → 0.23.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/components/shared/overlay.js +1 -1
  2. package/components/ui-image/ui-image.html +177 -0
  3. package/components/ui-image/ui-image.js +107 -0
  4. package/components/ui-menu/ui-menu.js +1 -1
  5. package/components/ui-popover/ui-popover.js +4 -1
  6. package/components/ui-separator/ui-separator.html +18 -0
  7. package/dist/index.js +24 -23
  8. package/editor/chunks/extensions.js +633 -20
  9. package/editor/extensions/block-selection.d.ts +6 -0
  10. package/editor/extensions/expand.d.ts +31 -0
  11. package/editor/extensions/image.d.ts +12 -0
  12. package/editor/extensions/index.d.ts +4 -1
  13. package/editor/extensions/index.js +2 -2
  14. package/editor/extensions/preset.d.ts +8 -0
  15. package/editor/extensions/slash-command.d.ts +31 -3
  16. package/editor/extensions/table-of-contents.d.ts +58 -0
  17. package/editor/index.js +2 -2
  18. package/package.json +1 -1
  19. package/styles/ui-image.css +153 -0
  20. package/styles/ui-separator.css +16 -0
  21. package/vanilla/UiImage.d.ts +21 -0
  22. package/vanilla/UiImage.js +84 -0
  23. package/vanilla/UiSeparator.d.ts +2 -0
  24. package/vanilla/UiSeparator.js +10 -2
  25. package/vanilla/index.js +1 -0
  26. package/vue/UiImage.d.ts +100 -0
  27. package/vue/UiImage.js +2 -0
  28. package/vue/UiImage.vue +463 -0
  29. package/vue/UiSeparator.d.ts +18 -0
  30. package/vue/UiSeparator.vue +66 -1
  31. package/vue/UiSidebar.d.ts +1 -1
  32. package/vue/chunks/UiImage.js +313 -0
  33. package/vue/chunks/UiSeparator.js +79 -22
  34. package/vue/components.css +169 -9
  35. package/vue/editor/LoomaEditor.d.ts +19 -2
  36. package/vue/editor/divider.d.ts +6 -0
  37. package/vue/editor/expand.d.ts +7 -0
  38. package/vue/editor/image-controls.d.ts +19 -0
  39. package/vue/editor/image-delivery.d.ts +2 -2
  40. package/vue/editor/image.d.ts +7 -0
  41. package/vue/editor/index.d.ts +1 -1
  42. package/vue/editor/index.js +1188 -52
  43. package/vue/editor/table-of-contents.d.ts +7 -0
  44. package/vue/index.d.ts +1 -0
  45. package/vue/index.js +2 -1
@@ -31,7 +31,7 @@ function requestClose(document, reason, trigger) {
31
31
  function ensureListeners(document, state) {
32
32
  if (state.listening) return;
33
33
  state.onKeydown = (event) => {
34
- if (event.key === "Escape") requestClose(document, "escape", "keyboard");
34
+ if (event.key === "Escape" && !event.defaultPrevented && requestClose(document, "escape", "keyboard")) event.preventDefault();
35
35
  };
36
36
  state.onPointerdown = (event) => {
37
37
  const top = state.records.at(-1);
@@ -0,0 +1,177 @@
1
+ <template component="ui-image" controller="./ui-image.js">
2
+ <defs>
3
+ <prop name="src" type="string" default="">The image source, used by the default media.</prop>
4
+ <prop name="alt" type="string" default="">A text alternative for the image.</prop>
5
+ <prop name="width" type="integer" nullable
6
+ >Image width in CSS pixels; omitted uses intrinsic size in block/center placement or responsive sizing when wrapping.</prop>
7
+ <prop name="height" type="integer" nullable>Image height in CSS pixels; resizing preserves the intrinsic aspect ratio.</prop>
8
+ <prop name="placement" type="keyword" values="block, center, wrap-left, wrap-right" default="block"
9
+ >Block keeps text below; center centers the image; wrap-left and wrap-right let following text flow beside it.</prop>
10
+ <prop name="selected" type="boolean" default="false">Outlines the media as the current selection.</prop>
11
+ <prop name="resizable" type="boolean" default="false"
12
+ >Shows four corner handles while selected. Drag to resize, or focus a handle and use arrow keys; Shift uses a larger increment.</prop>
13
+ <event name="resize" type="object">
14
+ <prop name="width" type="integer" required></prop>
15
+ <prop name="height" type="integer" required></prop>
16
+ <prop name="phase" type="keyword" values="preview, commit, cancel" required></prop>
17
+ <prop name="trigger" type="keyword" values="pointer, keyboard" required></prop>
18
+ Reports resize intent. Preview is transient; the consumer persists a commit. Escape and pointer cancellation restore the original size.
19
+ </event>
20
+ </defs>
21
+ <figure>
22
+ <div class="frame" $ref="frame">
23
+ <slot name="media"><img from:src="src" from:alt="alt" from:width="width" from:height="height"></slot>
24
+ <div class="handles" from:hidden="not (selected and resizable)">
25
+ <button type="button" class="handle" data-corner="top-left" aria-label="Resize image from top left"></button>
26
+ <button type="button" class="handle" data-corner="top-right" aria-label="Resize image from top right"></button>
27
+ <button type="button" class="handle" data-corner="bottom-left" aria-label="Resize image from bottom left"></button>
28
+ <button type="button" class="handle" data-corner="bottom-right" aria-label="Resize image from bottom right"></button>
29
+ </div>
30
+ <slot name="controls"></slot>
31
+ </div>
32
+ </figure>
33
+ <style>
34
+ :host {
35
+ display: flex;
36
+ justify-content: flex-start;
37
+ box-sizing: border-box;
38
+ max-inline-size: 100%;
39
+ min-inline-size: 0;
40
+ margin: 0;
41
+ padding: var(--ui-space-3) 0;
42
+ }
43
+ :host-state([placement="center"]) {
44
+ justify-content: center;
45
+ }
46
+ :host-state([placement="wrap-left"]) {
47
+ float: left;
48
+ padding-inline-end: var(--ui-space-4);
49
+ }
50
+ :host-state([placement="wrap-right"]) {
51
+ float: right;
52
+ padding-inline-start: var(--ui-space-4);
53
+ }
54
+ :host:is(:host-state([placement="wrap-left"]), :host-state([placement="wrap-right"])) {
55
+ inline-size: calc(var(--_ui-image-width, calc(100% / 3)) + var(--ui-space-4));
56
+ }
57
+ :host:is(:host-state([placement="wrap-left"]), :host-state([placement="wrap-right"])) .frame {
58
+ inline-size: 100%;
59
+ }
60
+ :host:is(:host-state([placement="wrap-left"]), :host-state([placement="wrap-right"])) .frame img,
61
+ :host:is(:host-state([placement="wrap-left"]), :host-state([placement="wrap-right"])) .frame :slotted(img) {
62
+ inline-size: 100%;
63
+ }
64
+ .frame {
65
+ position: relative;
66
+ flex: 0 1 auto;
67
+ inline-size: var(--_ui-image-width, fit-content);
68
+ max-inline-size: 100%;
69
+ min-inline-size: 0;
70
+ align-self: flex-start;
71
+ border-radius: var(--ui-radius-md);
72
+ }
73
+ .frame img, .frame :slotted(img) {
74
+ display: block;
75
+ max-inline-size: 100%;
76
+ block-size: auto;
77
+ margin: 0;
78
+ border-radius: inherit;
79
+ }
80
+ :host[data-resizing] .frame img, :host[data-resizing] .frame :slotted(img) {
81
+ inline-size: 100%;
82
+ }
83
+ :host-state([selected]) .frame {
84
+ outline: var(--ui-focus-width) solid var(--ui-focus-ring);
85
+ outline-offset: var(--ui-border-width);
86
+ }
87
+ .handle {
88
+ position: absolute;
89
+ z-index: 1;
90
+ inline-size: 24px;
91
+ block-size: 24px;
92
+ padding: 0;
93
+ border: 0;
94
+ background: transparent;
95
+ touch-action: none;
96
+ }
97
+ .handle::after {
98
+ content: "";
99
+ position: absolute;
100
+ inset: 8px;
101
+ background: var(--ui-surface);
102
+ border: var(--ui-border-width) solid var(--ui-focus-ring);
103
+ border-radius: var(--ui-radius-sm);
104
+ }
105
+ .handle:focus-visible {
106
+ outline: var(--ui-focus-width) solid var(--ui-focus-ring);
107
+ border-radius: var(--ui-radius-sm);
108
+ }
109
+ .handle[data-corner="top-left"] {
110
+ top: -12px;
111
+ left: -12px;
112
+ cursor: nwse-resize;
113
+ }
114
+ .handle[data-corner="top-right"] {
115
+ top: -12px;
116
+ right: -12px;
117
+ cursor: nesw-resize;
118
+ }
119
+ .handle[data-corner="bottom-left"] {
120
+ bottom: -12px;
121
+ left: -12px;
122
+ cursor: nesw-resize;
123
+ }
124
+ .handle[data-corner="bottom-right"] {
125
+ bottom: -12px;
126
+ right: -12px;
127
+ cursor: nwse-resize;
128
+ }
129
+ .handles[hidden], :host[hidden] {
130
+ display: none;
131
+ }
132
+ @media (max-width: 767px) {
133
+ :host:is(:host-state([placement="wrap-left"]), :host-state([placement="wrap-right"])) {
134
+ float: none;
135
+ inline-size: 100%;
136
+ padding-inline: 0;
137
+ }
138
+ :host:is(:host-state([placement="wrap-left"]), :host-state([placement="wrap-right"])) .handles {
139
+ display: none;
140
+ }
141
+ }
142
+ @media (pointer: coarse) {
143
+ .handle {
144
+ inline-size: 44px;
145
+ block-size: 44px;
146
+ }
147
+ .handle::after {
148
+ inset: 18px;
149
+ }
150
+ .handle[data-corner="top-left"] {
151
+ top: -22px;
152
+ left: -22px;
153
+ }
154
+ .handle[data-corner="top-right"] {
155
+ top: -22px;
156
+ right: -22px;
157
+ }
158
+ .handle[data-corner="bottom-left"] {
159
+ bottom: -22px;
160
+ left: -22px;
161
+ }
162
+ .handle[data-corner="bottom-right"] {
163
+ bottom: -22px;
164
+ right: -22px;
165
+ }
166
+ }
167
+ @media (forced-colors: active) {
168
+ :host-state([selected]) .frame, .handle:focus-visible {
169
+ outline-color: Highlight;
170
+ }
171
+ .handle::after {
172
+ border-color: Highlight;
173
+ background: Canvas;
174
+ }
175
+ }
176
+ </style>
177
+ </template>
@@ -0,0 +1,107 @@
1
+ /** Owns transient resize geometry; consumers own persistence and selection.
2
+ * @lifecycle Pointer capture ends on release, Escape, cancellation, or teardown.
3
+ * Only release commits a drag, so consumers can make it one undo operation.
4
+ */
5
+ export default function controller(host) {
6
+ const root = host.element;
7
+ const frame = host.refs.frame;
8
+ const document = root.ownerDocument;
9
+ let drag = null;
10
+ const image = () => frame.querySelector("img");
11
+ const enabled = () => host.props.selected.value && host.props.resizable.value;
12
+ const measure = () => {
13
+ const media = image();
14
+ const rect = media.getBoundingClientRect();
15
+ return { width: rect.width, height: rect.height, ratio: (media.naturalHeight || rect.height || 1) / (media.naturalWidth || rect.width || 1) };
16
+ };
17
+ const dimensions = (width, ratio) => {
18
+ // The containing document bounds the media even when this figure floats.
19
+ const container = root.parentElement;
20
+ const style = document.defaultView.getComputedStyle(container);
21
+ const available = container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
22
+ const gap = parseFloat(document.defaultView.getComputedStyle(root).paddingLeft) + parseFloat(document.defaultView.getComputedStyle(root).paddingRight);
23
+ const maximum = Math.max(1, available - gap);
24
+ const next = Math.round(Math.min(maximum, Math.max(Math.min(32, maximum), width)));
25
+ return { width: next, height: Math.max(1, Math.round(next * ratio)) };
26
+ };
27
+ const restore = () => {
28
+ root.removeAttribute("data-resizing");
29
+ const width = host.props.width.value;
30
+ if (width > 0) root.style.setProperty("--_ui-image-width", `${width}px`);
31
+ else root.style.removeProperty("--_ui-image-width");
32
+ };
33
+ const dispatch = (size, phase, trigger) => host.dispatch("resize", { ...size, phase, trigger });
34
+ const finish = (phase) => {
35
+ if (!drag) return;
36
+ const current = drag;
37
+ drag = null;
38
+ document.removeEventListener("keydown", onEscape, true);
39
+ if (current.handle.hasPointerCapture(current.pointer)) current.handle.releasePointerCapture(current.pointer);
40
+ if (phase === "commit" && !current.moved) phase = "cancel";
41
+ restore();
42
+ dispatch(phase === "commit" ? current.size : { width: Math.round(current.width), height: Math.round(current.height) }, phase, "pointer");
43
+ };
44
+ const onDown = (event) => {
45
+ const handle = event.target.closest("[data-corner]");
46
+ if (!enabled() || !handle || !root.contains(handle) || event.button !== 0 || drag) return;
47
+ event.preventDefault();
48
+ const measured = measure();
49
+ drag = { ...measured, x: event.clientX, y: event.clientY, handle, pointer: event.pointerId, size: dimensions(measured.width, measured.ratio) };
50
+ handle.setPointerCapture(event.pointerId);
51
+ document.addEventListener("keydown", onEscape, true);
52
+ root.setAttribute("data-resizing", "");
53
+ };
54
+ const onMove = (event) => {
55
+ if (!drag || drag.pointer !== event.pointerId) return;
56
+ const corner = drag.handle.dataset.corner;
57
+ const dx = (event.clientX - drag.x) * (corner.endsWith("left") ? -1 : 1);
58
+ const dy = (event.clientY - drag.y) * (corner.startsWith("top") ? -1 : 1);
59
+ // Project onto the aspect-ratio diagonal: either axis can drive a corner.
60
+ const delta = (dx + drag.ratio * dy) / (1 + drag.ratio * drag.ratio);
61
+ drag.size = dimensions(drag.width + delta, drag.ratio);
62
+ drag.moved = drag.size.width !== Math.round(drag.width);
63
+ root.style.setProperty("--_ui-image-width", `${drag.size.width}px`);
64
+ dispatch(drag.size, "preview", "pointer");
65
+ };
66
+ const onUp = event => { if (drag?.pointer === event.pointerId) finish("commit"); };
67
+ const onCancel = event => { if (drag?.pointer === event.pointerId) finish("cancel"); };
68
+ const onEscape = (event) => {
69
+ if (event.key !== "Escape" || !drag) return;
70
+ event.preventDefault(); event.stopPropagation(); finish("cancel");
71
+ };
72
+ const onKey = (event) => {
73
+ const handle = event.target.closest?.("[data-corner]");
74
+ if (!enabled() || !handle || !root.contains(handle) || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
75
+ event.preventDefault(); event.stopPropagation();
76
+ const measured = measure();
77
+ const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
78
+ const backwards = event.key === "ArrowLeft" || event.key === "ArrowUp";
79
+ const reverse = horizontal ? handle.dataset.corner.endsWith("left") : handle.dataset.corner.startsWith("top");
80
+ const amount = (backwards !== reverse ? -1 : 1) * (event.shiftKey ? 32 : 8);
81
+ dispatch(dimensions(measured.width + amount / (horizontal ? 1 : measured.ratio), measured.ratio), "commit", "keyboard");
82
+ };
83
+ const stop = host.effect(() => {
84
+ // Read all external geometry before deciding whether to abandon a preview.
85
+ host.props.width.value; host.props.height.value; host.props.placement.value;
86
+ if (drag) finish("cancel");
87
+ restore();
88
+ enabled();
89
+ });
90
+ root.addEventListener("pointerdown", onDown);
91
+ root.addEventListener("pointermove", onMove);
92
+ root.addEventListener("pointerup", onUp);
93
+ root.addEventListener("pointercancel", onCancel);
94
+ root.addEventListener("lostpointercapture", onCancel);
95
+ root.addEventListener("keydown", onKey);
96
+ return () => {
97
+ stop();
98
+ if (drag) { const active = drag; drag = null; if (active.handle.hasPointerCapture(active.pointer)) active.handle.releasePointerCapture(active.pointer); }
99
+ root.removeEventListener("pointerdown", onDown);
100
+ root.removeEventListener("pointermove", onMove);
101
+ root.removeEventListener("pointerup", onUp);
102
+ root.removeEventListener("pointercancel", onCancel);
103
+ root.removeEventListener("lostpointercapture", onCancel);
104
+ root.removeEventListener("keydown", onKey);
105
+ document.removeEventListener("keydown", onEscape, true);
106
+ };
107
+ }
@@ -104,7 +104,7 @@ export default function controller(host) {
104
104
  const onClick = (event) => select(menuItemFrom(event.target), triggerFor(event));
105
105
  const onKeydown = (event) => {
106
106
  if (event.key === "Escape") {
107
- if (host.props.inline.value) return;
107
+ if (host.props.inline.value || event.defaultPrevented) return;
108
108
  event.preventDefault();
109
109
  requestTopOverlayClose(document, "escape", "keyboard");
110
110
  return;
@@ -81,7 +81,10 @@ export default function controller(host) {
81
81
  lastOpen = open;
82
82
  };
83
83
  const onKeydown = (event) => {
84
- if (event.key === "Escape") requestTopOverlayClose(document, "escape", "keyboard");
84
+ if (event.key === "Escape" && !event.defaultPrevented) {
85
+ event.preventDefault();
86
+ requestTopOverlayClose(document, "escape", "keyboard");
87
+ }
85
88
  };
86
89
  element.addEventListener("keydown", onKeydown);
87
90
  const stop = host.effect(apply);
@@ -2,6 +2,8 @@
2
2
  <defs>
3
3
  <prop name="orientation" type="keyword" values="horizontal, vertical" default="horizontal"
4
4
  >Whether the separator divides stacked content (horizontal) or inline content (vertical).</prop>
5
+ <prop name="selectable" type="boolean" default="false">Gives a document divider a pointer-sized hit area. The editor owns selection and deletion.</prop>
6
+ <prop name="selected" type="boolean" default="false">Shows the selected document divider. Selection does not change separator semantics.</prop>
5
7
  </defs>
6
8
  <hr from:aria-orientation="orientation">
7
9
  <style>
@@ -25,5 +27,21 @@
25
27
  border-block-start: 0;
26
28
  border-inline-start: var(--ui-border-width) solid var(--ui-separator-color, var(--ui-border-divider));
27
29
  }
30
+
31
+ :host-state([selectable]) {
32
+ min-block-size: var(--ui-control-size-sm);
33
+ border: 0;
34
+ background: linear-gradient(var(--ui-separator-color, var(--ui-border-divider)), var(--ui-separator-color, var(--ui-border-divider))) center / 100%
35
+ var(--ui-border-width) no-repeat;
36
+ cursor: pointer;
37
+ }
38
+ :host-state([selectable]):hover {
39
+ background-color: var(--ui-surface-hover);
40
+ }
41
+ :host-state([selected]) {
42
+ outline: var(--ui-focus-width) solid var(--ui-focus-ring);
43
+ outline-offset: var(--ui-focus-width);
44
+ border-radius: var(--ui-radius-sm);
45
+ }
28
46
  </style>
29
47
  </template>