@nocobase/client-v2 2.1.18 → 2.1.20

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 (30) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/flow/admin-shell/BaseLayoutModel.d.ts +2 -0
  3. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +1 -0
  4. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +0 -1
  5. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  6. package/es/index.d.ts +1 -0
  7. package/es/index.mjs +93 -93
  8. package/es/utils/markdownSanitize.d.ts +11 -0
  9. package/lib/index.js +114 -114
  10. package/package.json +7 -7
  11. package/src/BaseApplication.tsx +4 -0
  12. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  13. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
  14. package/src/flow/actions/dataScopeFilter.ts +10 -1
  15. package/src/flow/actions/openView.tsx +21 -1
  16. package/src/flow/admin-shell/BaseLayoutModel.tsx +5 -0
  17. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +28 -12
  18. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +120 -0
  19. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +48 -0
  20. package/src/flow/common/Markdown/Display.tsx +14 -3
  21. package/src/flow/common/Markdown/Edit.tsx +19 -7
  22. package/src/flow/internal/components/Markdown/util.ts +2 -1
  23. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +13 -18
  24. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +30 -1
  25. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -0
  26. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  27. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  28. package/src/flow/routeTransientInputArgs.ts +27 -0
  29. package/src/index.ts +1 -0
  30. package/src/utils/markdownSanitize.ts +88 -0
@@ -303,6 +303,54 @@ describe('AdminLayoutModel runtime', () => {
303
303
  });
304
304
  });
305
305
 
306
+ it('should keep route state when route page registers after route sync', async () => {
307
+ const engine = new FlowEngine();
308
+
309
+ render(
310
+ <FlowEngineProvider engine={engine}>
311
+ <TestAdminLayoutHost />
312
+ </FlowEngineProvider>,
313
+ );
314
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
315
+ const routeState = {
316
+ __nocobaseOpenViewInputArgs: {
317
+ popup: {
318
+ formData: {
319
+ start: '2026-06-24',
320
+ end: '2026-06-25',
321
+ },
322
+ },
323
+ },
324
+ };
325
+
326
+ act(() => {
327
+ model.syncLayoutRoute({
328
+ name: getLayoutPageViewRouteName('admin'),
329
+ pathname: '/admin/page-1/view/popup',
330
+ layoutBasePathname: '/admin',
331
+ state: routeState,
332
+ });
333
+ });
334
+
335
+ const coordinator = (model as any).getCoordinator();
336
+ const syncRoute = vi.spyOn(coordinator, 'syncRoute').mockImplementation(() => undefined);
337
+
338
+ act(() => {
339
+ model.registerRoutePage('page-1', {
340
+ active: true,
341
+ layoutContentElement: document.createElement('div'),
342
+ });
343
+ });
344
+
345
+ expect(syncRoute).toHaveBeenCalledWith(
346
+ expect.objectContaining({
347
+ pageUid: 'page-1',
348
+ pathname: '/admin/page-1/view/popup',
349
+ state: routeState,
350
+ }),
351
+ );
352
+ });
353
+
306
354
  it('should parse RunJS openView route params into layout route view stack state', async () => {
307
355
  const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
308
356
  if (!token) {
@@ -12,6 +12,7 @@ import { css } from '@emotion/css';
12
12
  import { createRoot } from 'react-dom/client';
13
13
  import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
14
14
  import Vditor from 'vditor';
15
+ import { removeMarkdownIframes, stripMarkdownIframes } from '../../../utils/markdownSanitize';
15
16
  import { useCDN } from './useCDN';
16
17
  import useStyle from './style';
17
18
 
@@ -51,8 +52,15 @@ function DisplayInner(props: { value: string; style?: CSSProperties; loadImages?
51
52
  Vditor.preview(containerRef.current, props.value ?? '', {
52
53
  mode: 'light',
53
54
  cdn,
54
- });
55
+ markdown: {
56
+ sanitize: true,
57
+ },
58
+ transform: stripMarkdownIframes,
59
+ })
60
+ .then(() => removeMarkdownIframes(containerRef.current))
61
+ .catch(() => removeMarkdownIframes(containerRef.current));
55
62
  setTimeout(() => {
63
+ removeMarkdownIframes(containerRef.current);
56
64
  containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => {
57
65
  img.style.cursor = 'zoom-in';
58
66
  img.addEventListener('click', () => {
@@ -129,13 +137,16 @@ export const Display = (props) => {
129
137
  Vditor.md2html(props.value, {
130
138
  mode: 'light',
131
139
  cdn,
140
+ markdown: {
141
+ sanitize: true,
142
+ },
132
143
  })
133
144
  .then((html) => {
134
- setText(convertToText(html));
145
+ setText(convertToText(stripMarkdownIframes(html)));
135
146
  })
136
147
  .catch(() => setText(''));
137
148
  }
138
- }, [props.value, textOnly]);
149
+ }, [props.value, textOnly, cdn]);
139
150
 
140
151
  const isOverflowTooltip = useCallback(() => {
141
152
  if (!elRef.current) return false;
@@ -17,6 +17,7 @@ import React, { useEffect, useCallback, useLayoutEffect, useMemo, useRef, useSta
17
17
  import { useTranslation } from 'react-i18next';
18
18
  import Vditor from 'vditor';
19
19
  import 'vditor/dist/index.css';
20
+ import { stripMarkdownIframeTags, stripMarkdownIframes } from '../../../utils/markdownSanitize';
20
21
  import { useCDN } from './useCDN';
21
22
  import useStyle from './style';
22
23
 
@@ -102,13 +103,20 @@ const Edit = (props) => {
102
103
  if (!containerRef.current) return;
103
104
 
104
105
  const toolbarConfig = placeToolbarTooltipsBelow(toolbar ?? defaultToolbar);
106
+ const safeValue = stripMarkdownIframeTags(value ?? '');
105
107
  const vditor = new Vditor(containerRef.current, {
106
- value: value ?? '',
108
+ value: safeValue,
107
109
  lang,
108
110
  cache: { enable: false },
109
111
  undoDelay: 0,
110
112
  mode: props.mode || 'ir',
111
- preview: { math: { engine: 'KaTeX' } },
113
+ preview: {
114
+ markdown: {
115
+ sanitize: true,
116
+ },
117
+ math: { engine: 'KaTeX' },
118
+ transform: stripMarkdownIframes,
119
+ },
112
120
  toolbar: toolbarConfig,
113
121
  fullscreen: { index: 1200 },
114
122
  cdn,
@@ -117,7 +125,6 @@ const Edit = (props) => {
117
125
  after: () => {
118
126
  vdRef.current = vditor;
119
127
  setEditorReady(true); // Notify that the editor is ready
120
- vditor.setValue(value ?? '');
121
128
  if (disabled) {
122
129
  vditor.disabled();
123
130
  } else {
@@ -131,8 +138,12 @@ const Edit = (props) => {
131
138
  });
132
139
  }
133
140
  },
134
- input(value) {
135
- onChange(value);
141
+ input(nextValue) {
142
+ const safeNextValue = stripMarkdownIframeTags(nextValue);
143
+ if (safeNextValue !== nextValue) {
144
+ vditor.setValue(safeNextValue);
145
+ }
146
+ onChange(safeNextValue);
136
147
  },
137
148
  upload: {
138
149
  multiple: false,
@@ -229,8 +240,9 @@ const Edit = (props) => {
229
240
  useEffect(() => {
230
241
  if (editorReady && vdRef.current) {
231
242
  const editor = vdRef.current;
232
- if (value !== editor.getValue()) {
233
- editor.setValue(value ?? '');
243
+ const safeValue = stripMarkdownIframeTags(value ?? '');
244
+ if (safeValue !== editor.getValue()) {
245
+ editor.setValue(safeValue);
234
246
 
235
247
  const preArea = containerRef.current?.querySelector(
236
248
  'div.vditor-content > div.vditor-ir > pre',
@@ -9,13 +9,14 @@
9
9
 
10
10
  import _ from 'lodash';
11
11
  import { useEffect, useState } from 'react';
12
+ import { stripMarkdownIframes } from '../../../../utils/markdownSanitize';
12
13
 
13
14
  export const parseMarkdown = _.memoize(async (text: string) => {
14
15
  if (!text) {
15
16
  return text;
16
17
  }
17
18
  const m = await import('./md');
18
- return m.default.render(text);
19
+ return stripMarkdownIframes(m.default.render(text));
19
20
  });
20
21
 
21
22
  export function useParseMarkdown(text: string) {
@@ -22,7 +22,6 @@ import {
22
22
  createRecordResolveOnServerWithLocal,
23
23
  } from '@nocobase/flow-engine';
24
24
  import { Button, Form, Skeleton, Space } from 'antd';
25
- import _ from 'lodash';
26
25
  import React from 'react';
27
26
  import { FieldModel } from '../../base/FieldModel';
28
27
  import { FormComponent } from './FormBlockModel';
@@ -46,8 +45,6 @@ export class QuickEditFormModel extends FlowModel {
46
45
  declare resource: SingleRecordResource;
47
46
  declare collection: Collection;
48
47
 
49
- now: number = Date.now();
50
-
51
48
  viewContainer: any;
52
49
  __onSubmitSuccess;
53
50
  _fieldProps: any;
@@ -89,21 +86,22 @@ export class QuickEditFormModel extends FlowModel {
89
86
  onOk,
90
87
  sourceFieldModelUid,
91
88
  } = options;
92
- const model = flowEngine.createModel({
93
- use: 'QuickEditFormModel',
94
- stepParams: {
95
- quickEditFormSettings: {
96
- init: {
97
- dataSourceKey,
98
- collectionName,
99
- fieldPath,
89
+ const sourceFieldModel = sourceFieldModelUid ? flowEngine.getModel<FlowModel>(sourceFieldModelUid) : undefined;
90
+ const model = flowEngine.createModel(
91
+ {
92
+ use: 'QuickEditFormModel',
93
+ stepParams: {
94
+ quickEditFormSettings: {
95
+ init: {
96
+ dataSourceKey,
97
+ collectionName,
98
+ fieldPath,
99
+ },
100
100
  },
101
101
  },
102
102
  },
103
- }) as QuickEditFormModel;
104
-
105
- console.log('QuickEditFormModel.open2', Date.now() - model.now);
106
- model.now = Date.now();
103
+ sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
104
+ ) as QuickEditFormModel;
107
105
 
108
106
  await flowEngine.context.viewer.open({
109
107
  type: 'popover',
@@ -122,7 +120,6 @@ export class QuickEditFormModel extends FlowModel {
122
120
  model.__onSubmitSuccess = onSuccess;
123
121
  model._fieldProps = fieldProps;
124
122
  model._onOk = onOk;
125
- console.log('QuickEditFormModel.open3', Date.now() - model.now);
126
123
  return (
127
124
  <FlowModelRenderer
128
125
  fallback={<Skeleton.Input size="small" />}
@@ -171,8 +168,6 @@ export class QuickEditFormModel extends FlowModel {
171
168
  }
172
169
 
173
170
  render() {
174
- console.log('QuickEditFormModel.open4', Date.now() - this.now);
175
-
176
171
  return (
177
172
  <FormComponent model={this}>
178
173
  <div
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach } from 'vitest';
11
- import { FlowEngine, SingleRecordResource } from '@nocobase/flow-engine';
11
+ import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
12
12
  import { QuickEditFormModel } from '../QuickEditFormModel';
13
13
 
14
14
  describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
@@ -18,6 +18,35 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
18
18
  engine = new FlowEngine();
19
19
  });
20
20
 
21
+ it('uses source field context when opening quick edit', async () => {
22
+ engine.registerModels({ QuickEditFormModel });
23
+ engine.context.defineProperty('pageActive', { value: { value: false } });
24
+ engine.context.defineProperty('viewer', { value: { open: vi.fn(async () => undefined) } });
25
+
26
+ const page = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'page' });
27
+ page.context.defineProperty('pageActive', { value: { value: true } });
28
+ const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field', parentId: page.uid });
29
+
30
+ await QuickEditFormModel.open({
31
+ flowEngine: engine,
32
+ target: document.createElement('div'),
33
+ dataSourceKey: 'main',
34
+ collectionName: 'users',
35
+ fieldPath: 'name',
36
+ record: {},
37
+ sourceFieldModelUid: source.uid,
38
+ });
39
+
40
+ let quickEditModel: QuickEditFormModel | undefined;
41
+ engine.forEachModel((model) => {
42
+ if (model instanceof QuickEditFormModel) {
43
+ quickEditModel = model;
44
+ }
45
+ });
46
+
47
+ expect(quickEditModel?.context.pageActive.value).toBe(true);
48
+ });
49
+
21
50
  it('calls update with filterByTk and merges primary key from ctx.collection/record', async () => {
22
51
  // 1) 准备数据源与集合(含主键字段)
23
52
  const dsm = engine.context.dataSourceManager;
@@ -126,6 +126,7 @@ const RenderCell = observer<any>((props) => {
126
126
  fieldPath: dataIndex,
127
127
  record: record,
128
128
  fieldProps: { ...columnModel.props, ...columnModel.subModels.field.props },
129
+ sourceFieldModelUid: columnModel.subModels.field.uid,
129
130
  onOk: (values) => {
130
131
  record[dataIndex] = values[dataIndex];
131
132
  // 仅重渲染单元格
@@ -9,56 +9,79 @@
9
9
 
10
10
  import { Input } from 'antd';
11
11
  import type { TextAreaProps } from 'antd/es/input';
12
- import type { TextAreaRef } from 'antd/es/input/TextArea';
13
- import React, { useEffect, useRef } from 'react';
12
+ import React, { useEffect, useRef, useState } from 'react';
14
13
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
15
14
  import { FieldModel } from '../base/FieldModel';
16
15
 
17
16
  function IMESafeTextArea(props: TextAreaProps) {
18
17
  const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
19
- const textAreaRef = useRef<TextAreaRef>(null);
20
- const previousValueRef = useRef(value);
21
- const defaultValue = typeof value === 'bigint' ? String(value) : value;
18
+ const [textAreaValue, setTextAreaValue] = useState<TextAreaProps['value']>(() => normalizeTextAreaValue(value));
19
+ const textAreaValueRef = useRef<TextAreaProps['value']>(textAreaValue);
20
+ const pendingLocalValuesRef = useRef<TextAreaProps['value'][]>([]);
22
21
 
23
22
  useEffect(() => {
24
- if (Object.is(previousValueRef.current, value)) {
25
- return;
26
- }
27
- previousValueRef.current = value;
23
+ const nextValue = normalizeTextAreaValue(value);
24
+ const pendingValues = pendingLocalValuesRef.current;
25
+ const pendingIndex = pendingValues.findIndex((item) => Object.is(item, nextValue));
28
26
 
29
- const textArea = textAreaRef.current?.resizableTextArea?.textArea;
30
- if (textArea) {
31
- const nextValue = value == null ? '' : String(value);
32
- if (textArea.value !== nextValue) {
33
- textArea.value = nextValue;
27
+ if (pendingIndex >= 0) {
28
+ pendingValues.splice(0, pendingIndex + 1);
29
+ if (!Object.is(textAreaValueRef.current, nextValue)) {
30
+ return;
34
31
  }
35
32
  }
33
+
34
+ pendingValues.length = 0;
35
+ if (Object.is(textAreaValueRef.current, nextValue)) {
36
+ return;
37
+ }
38
+
39
+ textAreaValueRef.current = nextValue;
40
+ setTextAreaValue(nextValue);
36
41
  }, [value]);
37
42
 
38
43
  const getEventValue = (event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>) =>
39
44
  event.currentTarget.value;
40
45
 
46
+ const recordLocalValue = (
47
+ event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>,
48
+ ) => {
49
+ const nextValue = getEventValue(event);
50
+ const pendingValues = pendingLocalValuesRef.current;
51
+ pendingValues.push(nextValue);
52
+ if (pendingValues.length > 20) {
53
+ pendingValues.shift();
54
+ }
55
+ textAreaValueRef.current = nextValue;
56
+ setTextAreaValue(nextValue);
57
+ };
58
+
41
59
  return (
42
60
  <Input.TextArea
43
61
  {...rest}
44
- ref={textAreaRef}
45
- defaultValue={defaultValue}
62
+ value={textAreaValue}
46
63
  onChange={(event) => {
47
- previousValueRef.current = getEventValue(event);
64
+ recordLocalValue(event);
48
65
  onChange?.(event);
49
66
  }}
50
67
  onCompositionStart={(event) => {
51
- previousValueRef.current = getEventValue(event);
68
+ recordLocalValue(event);
52
69
  onCompositionStart?.(event);
53
70
  }}
54
71
  onCompositionEnd={(event) => {
55
- previousValueRef.current = getEventValue(event);
72
+ recordLocalValue(event);
56
73
  onCompositionEnd?.(event);
57
74
  }}
58
75
  />
59
76
  );
60
77
  }
61
78
 
79
+ function normalizeTextAreaValue(nextValue: unknown): TextAreaProps['value'] {
80
+ if (typeof nextValue === 'bigint') return String(nextValue);
81
+ if (nextValue == null) return '';
82
+ return nextValue as TextAreaProps['value'];
83
+ }
84
+
62
85
  @largeField()
63
86
  export class TextareaFieldModel extends FieldModel {
64
87
  render() {
@@ -7,8 +7,9 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { FlowEngine } from '@nocobase/flow-engine';
10
+ import { FieldModelRenderer, FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
11
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
12
+ import { Form } from 'antd';
12
13
  import React from 'react';
13
14
  import { describe, expect, it, vi } from 'vitest';
14
15
  import { TextareaFieldModel } from '../TextareaFieldModel';
@@ -23,6 +24,36 @@ function createTextareaFieldModel(props?: Record<string, unknown>) {
23
24
  }
24
25
 
25
26
  describe('TextareaFieldModel', () => {
27
+ it('syncs multiline values assigned through the form renderer', async () => {
28
+ const flowEngine = new FlowEngine();
29
+ const model = createTextareaFieldModel();
30
+ model.dispatchEvent = vi.fn().mockResolvedValue([]);
31
+
32
+ function FormHost() {
33
+ const [form] = Form.useForm();
34
+
35
+ React.useEffect(() => {
36
+ form.setFieldsValue({ address: 'aaaaaa\nbbbbbb' });
37
+ }, [form]);
38
+
39
+ return (
40
+ <FlowEngineProvider engine={flowEngine}>
41
+ <Form form={form}>
42
+ <Form.Item name="address">
43
+ <FieldModelRenderer model={model} />
44
+ </Form.Item>
45
+ </Form>
46
+ </FlowEngineProvider>
47
+ );
48
+ }
49
+
50
+ render(<FormHost />);
51
+
52
+ await waitFor(() => {
53
+ expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe('aaaaaa\nbbbbbb');
54
+ });
55
+ });
56
+
26
57
  it('keeps IME composition text visible before the parent value is committed', () => {
27
58
  const onChange = vi.fn();
28
59
  const onCompositionStart = vi.fn();
@@ -0,0 +1,27 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ export const ROUTE_TRANSIENT_INPUT_ARGS_KEY = '__nocobaseOpenViewInputArgs';
11
+
12
+ export type RouteTransientInputArgsState = {
13
+ [ROUTE_TRANSIENT_INPUT_ARGS_KEY]?: Record<string, Record<string, unknown>>;
14
+ usr?: RouteTransientInputArgsState;
15
+ };
16
+
17
+ export const getRouteTransientInputArgs = (state: unknown, viewUid?: string) => {
18
+ if (!viewUid || !state || typeof state !== 'object') {
19
+ return {};
20
+ }
21
+
22
+ const stateValue = state as RouteTransientInputArgsState;
23
+ const values = (stateValue[ROUTE_TRANSIENT_INPUT_ARGS_KEY] || stateValue.usr?.[ROUTE_TRANSIENT_INPUT_ARGS_KEY])?.[
24
+ viewUid
25
+ ];
26
+ return values && typeof values === 'object' ? values : {};
27
+ };
package/src/index.ts CHANGED
@@ -39,5 +39,6 @@ export * from './collection-manager/interfaces';
39
39
  export * from './collection-manager/template-fields';
40
40
  export * from './data-source';
41
41
  export * from './flow';
42
+ export * from './utils/markdownSanitize';
42
43
  export { DEFAULT_DATA_SOURCE_KEY, isTitleField, isTitleFieldInterface } from './flow-compat';
43
44
  export { default as AntdAppProvider } from './theme/AntdAppProvider';
@@ -0,0 +1,88 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ export function stripMarkdownIframeTags(markdown: string) {
11
+ if (!markdown) {
12
+ return markdown;
13
+ }
14
+
15
+ let result = '';
16
+ let cursor = 0;
17
+
18
+ while (cursor < markdown.length) {
19
+ const iframeStart = markdown.slice(cursor).search(/<iframe\b/i);
20
+ if (iframeStart === -1) {
21
+ result += markdown.slice(cursor);
22
+ break;
23
+ }
24
+
25
+ const start = cursor + iframeStart;
26
+ result += markdown.slice(cursor, start);
27
+
28
+ const openingEnd = findTagEnd(markdown, start);
29
+ if (openingEnd === -1) {
30
+ break;
31
+ }
32
+
33
+ const openingTag = markdown.slice(start, openingEnd + 1);
34
+ if (/\/\s*>$/.test(openingTag)) {
35
+ cursor = openingEnd + 1;
36
+ continue;
37
+ }
38
+
39
+ const closingStart = markdown.slice(openingEnd + 1).search(/<\/iframe\s*>/i);
40
+ if (closingStart === -1) {
41
+ break;
42
+ }
43
+
44
+ cursor =
45
+ openingEnd + 1 + closingStart + markdown.slice(openingEnd + 1 + closingStart).match(/^<\/iframe\s*>/i)[0].length;
46
+ }
47
+
48
+ return result;
49
+ }
50
+
51
+ export function stripMarkdownIframes(html: string) {
52
+ if (!html || typeof DOMParser === 'undefined') {
53
+ return html;
54
+ }
55
+
56
+ const doc = new DOMParser().parseFromString(html, 'text/html');
57
+ doc.querySelectorAll('iframe').forEach((iframe) => iframe.remove());
58
+ return doc.body.innerHTML;
59
+ }
60
+
61
+ export function removeMarkdownIframes(container?: ParentNode | null) {
62
+ container?.querySelectorAll('iframe').forEach((iframe) => iframe.remove());
63
+ }
64
+
65
+ function findTagEnd(html: string, start: number) {
66
+ let quote: string | undefined;
67
+
68
+ for (let index = start; index < html.length; index += 1) {
69
+ const char = html[index];
70
+ if (quote) {
71
+ if (char === quote) {
72
+ quote = undefined;
73
+ }
74
+ continue;
75
+ }
76
+
77
+ if (char === '"' || char === "'") {
78
+ quote = char;
79
+ continue;
80
+ }
81
+
82
+ if (char === '>') {
83
+ return index;
84
+ }
85
+ }
86
+
87
+ return -1;
88
+ }